Files
music-mirror/tools/submit_scrobbles.py
T

256 lines
8.9 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Submit a Rockbox scrobbler log to Last.fm, then set it aside.
Rockbox writes /.scrobbler.log on the device in AUDIOSCROBBLER 1.1 format: one
tab-separated line per track, rated `L` for listened or `S` for skipped. Only
the listened ones are submitted; a skip is not a play.
Scrobbling is a write method, so unlike everything else here it needs the API
secret and a session key, obtained once through the browser. Read-only calls
elsewhere in these projects need neither.
"""
import argparse
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
API_ROOT = "https://ws.audioscrobbler.com/2.0/"
# Last.fm's documented ceiling for one track.scrobble call.
BATCH = 50
# Rockbox names the log for whether the target has a real-time clock. Without
# one every timestamp it writes is zero, which is not a time anything can
# scrobble.
LOG_NAMES = (".scrobbler.log", ".scrobbler-timeless.log")
SESSION_FILE = Path(
os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")
) / "music-mirror" / "lastfm.json"
class LastfmError(Exception):
"""A Last.fm request that failed."""
def parse_log(text):
"""Return the listened tracks in an AUDIOSCROBBLER log, oldest first.
Fields are artist, album, title, track number, length, rating, timestamp
and MusicBrainz id. Rockbox converts any tab inside a field to a space
before writing, so splitting on tabs is safe.
"""
played, skipped, timeless = [], 0, 0
for line in text.splitlines():
if not line or line.startswith("#"):
continue
fields = line.split("\t")
if len(fields) < 7:
continue
artist, album, title, number, length, rating, timestamp = fields[:7]
mbid = fields[7] if len(fields) > 7 else ""
if rating.strip().upper() != "L":
skipped += 1
continue
try:
when = int(timestamp)
except ValueError:
continue
if when <= 0:
timeless += 1
continue
if not artist or not title:
continue
played.append(
{
"artist": artist,
"album": album,
"track": title,
"trackNumber": number if number not in ("", "-1") else "",
"duration": length if length.isdigit() and int(length) > 0 else "",
"timestamp": str(when),
"mbid": mbid,
}
)
played.sort(key=lambda entry: int(entry["timestamp"]))
return played, skipped, timeless
def sign(params, secret):
"""Return Last.fm's method signature for a set of parameters.
Names are sorted by the ASCII table rather than numerically, which is why
`artist[10]` comes before `artist[1]`. Getting that wrong produces an
invalid signature and nothing else.
"""
joined = "".join(f"{name}{params[name]}" for name in sorted(params))
return hashlib.md5((joined + secret).encode("utf-8")).hexdigest() # noqa: S324
def post(params, transport):
"""Sign, post, and return the decoded response."""
body = urllib.parse.urlencode(params).encode("utf-8")
request = urllib.request.Request(API_ROOT, data=body)
try:
payload = json.loads(transport(request))
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", "replace")[:300]
raise LastfmError(f"HTTP {error.code}: {detail}") from error
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
raise LastfmError(str(error)) from error
if payload.get("error"):
raise LastfmError(f"error {payload['error']}: {payload.get('message', '')}")
return payload
def call(method, params, key, secret, session, transport):
"""Make one signed, authenticated call."""
full = {**params, "method": method, "api_key": key}
if session:
full["sk"] = session
full["api_sig"] = sign(full, secret)
full["format"] = "json"
return post(full, transport)
def authorise(key, secret, transport, opener=print):
"""Walk the one-time browser authorisation and return a session key."""
token = call("auth.getToken", {}, key, secret, None, transport)["token"]
url = f"https://www.last.fm/api/auth/?api_key={key}&token={token}"
opener(f"Open this, approve the application, then press Enter:\n\n {url}\n")
input()
session = call("auth.getSession", {"token": token}, key, secret, None, transport)
return session["session"]["key"]
def load_session():
if SESSION_FILE.is_file():
return json.loads(SESSION_FILE.read_text(encoding="utf-8")).get("session")
return None
def save_session(session):
SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
SESSION_FILE.write_text(json.dumps({"session": session}), encoding="utf-8")
SESSION_FILE.chmod(0o600)
def batch_params(entries):
"""Return the indexed parameters for one track.scrobble call."""
params = {}
for index, entry in enumerate(entries):
for name in ("artist", "track", "timestamp", "album", "trackNumber", "duration", "mbid"):
if entry.get(name):
params[f"{name}[{index}]"] = entry[name]
return params
def submit(entries, key, secret, session, transport, delay=1.0):
"""Submit every entry. Returns how many the service accepted.
Batches are counted as they succeed rather than at the end, so a failure
partway through leaves an honest number and the caller can keep the rest of
the log instead of losing it.
"""
accepted = 0
for start in range(0, len(entries), BATCH):
chunk = entries[start : start + BATCH]
payload = call(
"track.scrobble", batch_params(chunk), key, secret, session, transport
)
block = payload.get("scrobbles", {})
summary = block.get("@attr", block)
accepted += int(summary.get("accepted", len(chunk)))
ignored = int(summary.get("ignored", 0))
if ignored:
print(f" {ignored} of {len(chunk)} ignored by Last.fm", file=sys.stderr)
if start + BATCH < len(entries):
time.sleep(delay)
return accepted
def http_post(request, timeout=30):
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
return response.read().decode("utf-8")
def find_log(device):
"""Return the scrobbler log on a mounted device, or None."""
for name in LOG_NAMES:
candidate = Path(device) / name
if candidate.is_file() and candidate.stat().st_size:
return candidate
return None
def main(argv=None, transport=http_post):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("device", help="the mounted device, or a scrobbler log file")
parser.add_argument("--api-key", default=os.getenv("LASTFM_API_KEY"))
parser.add_argument("--api-secret", default=os.getenv("LASTFM_API_SECRET"))
parser.add_argument("--dry-run", action="store_true", help="parse and report only")
parser.add_argument(
"--keep", action="store_true", help="do not set the log aside afterwards"
)
args = parser.parse_args(argv)
target = Path(args.device)
log = target if target.is_file() else find_log(target)
if log is None:
print("no scrobbler log to submit", file=sys.stderr)
return 0
played, skipped, timeless = parse_log(log.read_text(encoding="utf-8", errors="replace"))
print(f"{log}: {len(played)} listened, {skipped} skipped", file=sys.stderr)
if timeless:
print(
f" {timeless} entries have no timestamp, so this target has no clock."
" They cannot be scrobbled without inventing when they happened.",
file=sys.stderr,
)
if not played:
return 0
if args.dry_run:
for entry in played[:20]:
print(f"{entry['timestamp']}\t{entry['artist']}\t{entry['track']}")
return 0
if not args.api_key or not args.api_secret:
print(
"scrobbling is a write method: it needs LASTFM_API_KEY and"
" LASTFM_API_SECRET, not just the read-only key",
file=sys.stderr,
)
return 2
session = load_session()
if not session:
session = authorise(args.api_key, args.api_secret, transport)
save_session(session)
try:
accepted = submit(played, args.api_key, args.api_secret, session, transport)
except LastfmError as error:
print(f"submission failed: {error}", file=sys.stderr)
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.
aside = log.with_name(f"{log.name}.{played[-1]['timestamp']}.submitted")
log.rename(aside)
print(f"log moved to {aside.name}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())