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
+114 -27
View File
@@ -14,11 +14,13 @@ import argparse
import hashlib
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
API_ROOT = "https://ws.audioscrobbler.com/2.0/"
@@ -124,11 +126,16 @@ def device_to_local(path, device_prefix, mirror):
def read_tags(path, runner=None):
"""Return the tags of a local file, via ffprobe."""
"""Return the tags of a local file, via ffprobe. Empty if it cannot be read.
A file ffprobe chokes on is one play left unidentified, not a reason to
abandon the rest -- and CalledProcessError is not an OSError, so catching
the obvious things is not enough.
"""
runner = runner or _ffprobe
try:
payload = json.loads(runner(path))
except (OSError, ValueError):
except (OSError, ValueError, subprocess.SubprocessError):
return {}
return {
key.lower(): value
@@ -137,8 +144,6 @@ def read_tags(path, runner=None):
def _ffprobe(path):
import subprocess
return subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format_tags",
"-of", "json", str(path)],
@@ -146,28 +151,58 @@ def _ffprobe(path):
).stdout
@dataclass
class Conversion:
"""What a playback log turned into, and what must not be thrown away.
`retain` holds the raw lines of plays that were real but could not be
submitted -- a track absent from the mirror, usually because the sync had
not copied it yet. Those are written back so a later run can try again.
Skips and clockless entries are not retained: neither can ever be
submitted, and the untouched original is set aside regardless.
"""
played: list
skipped: int = 0
unresolved: int = 0
timeless: int = 0
retain: list = None
def __post_init__(self):
if self.retain is None:
self.retain = []
def plays_from_playback_log(text, device_prefix, mirror, runner=None):
"""Return submittable entries, plus counts of what was left out.
"""Return the conversion of a playback log.
Skips are decided by the same fraction the on-device plugin uses, so the
two never disagree about what counted as a play.
"""
played, skipped, unresolved, timeless = [], 0, 0, 0
for stamp, elapsed, length, device_path in parse_playback_log(text):
result = Conversion(played=[])
for line in text.splitlines():
parsed = parse_playback_log(line)
if not parsed:
continue
stamp, elapsed, length, device_path = parsed[0]
if stamp < EARLIEST_PLAUSIBLE:
timeless += 1
result.timeless += 1
continue
if length > 0 and elapsed < length * LISTENED_FRACTION:
skipped += 1
result.skipped += 1
continue
local = device_to_local(device_path, device_prefix, mirror)
tags = read_tags(local, runner) if local and local.is_file() else {}
artist = tags.get("artist") or tags.get("album_artist") or ""
title = tags.get("title") or ""
if not artist or not title:
unresolved += 1
# A real play of a track this run could not identify. Kept, so a
# later run -- after the file has been copied, or the tags fixed --
# can submit it rather than the play being lost.
result.unresolved += 1
result.retain.append(line)
continue
played.append(
result.played.append(
{
"artist": artist,
"track": title,
@@ -176,10 +211,11 @@ def plays_from_playback_log(text, device_prefix, mirror, runner=None):
"duration": str(length // 1000) if length > 0 else "",
"timestamp": str(stamp),
"mbid": tags.get("musicbrainz_trackid", ""),
"line": line,
}
)
played.sort(key=lambda entry: int(entry["timestamp"]))
return played, skipped, unresolved, timeless
result.played.sort(key=lambda entry: int(entry["timestamp"]))
return result
def find_playback_logs(device):
@@ -259,7 +295,7 @@ def batch_params(entries):
return params
def submit(entries, key, secret, session, transport, delay=1.0):
def submit(entries, key, secret, session, transport, delay=1.0, on_sent=None):
"""Submit every entry. Returns how many the service accepted.
Batches are counted as they succeed rather than at the end, so a failure
@@ -275,6 +311,8 @@ def submit(entries, key, secret, session, transport, delay=1.0):
block = payload.get("scrobbles", {})
summary = block.get("@attr", block)
accepted += int(summary.get("accepted", len(chunk)))
if on_sent is not None:
on_sent(chunk)
ignored = int(summary.get("ignored", 0))
if ignored:
print(f" {ignored} of {len(chunk)} ignored by Last.fm", file=sys.stderr)
@@ -324,6 +362,7 @@ def main(argv=None, transport=http_post):
log = target if target.is_file() else find_log(target)
logs = []
conversion = None
if log is not None:
played, skipped, timeless = parse_log(
log.read_text(encoding="utf-8", errors="replace")
@@ -341,8 +380,12 @@ def main(argv=None, transport=http_post):
text = "\n".join(
path.read_text(encoding="utf-8", errors="replace") for path in logs
)
played, skipped, unresolved, timeless = plays_from_playback_log(
text, args.device_prefix, args.mirror
conversion = plays_from_playback_log(text, args.device_prefix, args.mirror)
played = conversion.played
skipped, unresolved, timeless = (
conversion.skipped,
conversion.unresolved,
conversion.timeless,
)
print(
f"{len(logs)} playback log(s): {len(played)} listened, {skipped} skipped",
@@ -350,8 +393,8 @@ def main(argv=None, transport=http_post):
)
if unresolved:
print(
f" {unresolved} could not be matched to a file in the mirror"
" and were left out",
f" {unresolved} could not be matched to a file in the mirror."
" Those plays are kept for a later run rather than discarded.",
file=sys.stderr,
)
else:
@@ -388,22 +431,66 @@ def main(argv=None, transport=http_post):
session = authorise(args.api_key, args.api_secret, transport)
save_session(session)
# Recorded as each batch is accepted, so a failure partway through knows
# exactly what got through and what did not.
sent = []
try:
accepted = submit(played, args.api_key, args.api_secret, session, transport)
accepted = submit(
played, args.api_key, args.api_secret, session, transport,
on_sent=sent.extend,
)
except LastfmError as error:
print(f"submission failed: {error}", file=sys.stderr)
print(f"submission failed after {len(sent)} scrobbles: {error}", file=sys.stderr)
keep_history(logs, played, sent, conversion, args.keep)
return 1
print(f"{accepted} scrobbles accepted", file=sys.stderr)
if not args.keep and accepted:
# Renamed rather than deleted: if Last.fm quietly dropped something,
# the evidence is still on the device.
for path in logs:
aside = path.with_name(f"{path.name}.{played[-1]['timestamp']}.submitted")
path.rename(aside)
print(f"log moved to {aside.name}", file=sys.stderr)
keep_history(logs, played, sent, conversion, args.keep)
return 0
def keep_history(logs, played, sent, conversion, keep):
"""Set the logs aside, writing back anything still owed a submission.
Two separate obligations. The original is preserved untouched, renamed
rather than deleted, so a play is never lost to a mistake here. And any
play that was not submitted -- unmatched, or in a batch that failed -- is
written back into a live log, so the next run tries it again instead of it
quietly vanishing with the rest.
"""
if keep or not logs:
if keep:
print("logs left in place", file=sys.stderr)
return
submitted = {id(entry) for entry in sent}
# A .scrobbler.log was converted by the on-device plugin and carries no
# per-line record, so there is nothing to write back for it -- only the
# rename below, which loses nothing.
pending = list(conversion.retain) if conversion is not None else []
pending += [
entry["line"] for entry in played
if "line" in entry and id(entry) not in submitted
]
if not sent:
print("nothing was accepted; logs left untouched", file=sys.stderr)
return
stamp = played[-1]["timestamp"] if played else "0"
for path in logs:
path.rename(path.with_name(f"{path.name}.{stamp}.submitted"))
if pending:
live = logs[0].with_name("playback.log")
live.write_text("\n".join(pending) + "\n", encoding="utf-8")
print(
f"{len(pending)} plays not submitted were written back to"
f" {live.name} for the next run",
file=sys.stderr,
)
print(f"{len(logs)} log(s) set aside as .submitted", file=sys.stderr)
if __name__ == "__main__":
sys.exit(main())