256 lines
8.9 KiB
Python
256 lines
8.9 KiB
Python
#!/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())
|