Something went wrong. Try again.
Something went wrong. Try again.
6.1 kB · 144 lines
Python
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145#!/usr/bin/env python3"""Moves the ratings of a PDS from the old scale (1-10, one step = half a star) to 0-100 (one-off).
Usage: migrate_ratings.py --handle you.example.com --backup mediatracker-export-YYYY-MM-DD.zip \ [--pds https://pds.example.com] [--dry-run]
The `rating` of movie.rating, tvShow.rating and tvShow.episode.rating is multiplied by 10 (so 7 = 3.5 stars becomes70), each record under its own record key and with nothing else changed.
A value alone can't tell the two scales apart (10 is a full five stars before and a half star after), so the backupis the reference: a record is rewritten only while its rating in the PDS is still the one the backup holds (1-10),and it then gets that value times 10. A record that was rewritten already holds another value and is skipped, so itcan be run again at any time (after an interruption, or to check that nothing is left) with the SAME backup. A recordthat was created after the backup was made is left alone and reported.
NOTHING IS WRITTEN WITHOUT A BACKUP: `--backup` is the ZIP of the app's export (Settings -> Account -> Export, orGET /tracking/export), made before the first run. It must belong to this account and hold at least as many recordsper collection as the PDS does now. A dry run (`--dry-run`) works without one (then it only counts the values 1-9,which can't be new ones) and writes nothing.
The app password is read from the ATPROTO_APP_PASSWORD environment variable, or asked for. Afterwards use "Read dataagain" in the app's settings, so the record index reads the new records."""import argparseimport getpassimport jsonimport osimport sysimport zipfile
from import_to_pds import ( NAMESPACE, MOVIE_RATING, SHOW_RATING, EPISODE_RATING, MAX_WRITES_PER_CALL, Pds, XrpcError, rkey_of, _chunks,)from migrate_strongrefs import check_backup
COLLECTIONS = (MOVIE_RATING, SHOW_RATING, EPISODE_RATING)FACTOR = 10
def read_backup_ratings(path): """The rating of every record of the backup, by record uri.""" ratings = {} with zipfile.ZipFile(path) as archive: for collection in COLLECTIONS: try: records = json.loads(archive.read(f"{collection}.json")) except KeyError: continue for record in records: ratings[record["uri"]] = record["value"].get("rating")
return ratings
def migrate(pds, dry_run, backup): old = read_backup_ratings(backup) if backup else None pending = {} done = unknown = failed = 0 counts = {}
for collection in COLLECTIONS: records = pds.list_records(collection) counts[collection] = len(records) for record in records: rating = record["value"].get("rating") if not isinstance(rating, int): print(f" cannot rewrite {record['uri']}: no readable rating") failed += 1 elif old is None: if 1 <= rating <= 9: pending.setdefault(collection, []).append((rkey_of(record["uri"]), record["value"])) else: unknown += 1 elif old.get(record["uri"]) == rating and 1 <= rating <= 10: pending.setdefault(collection, []).append((rkey_of(record["uri"]), record["value"])) elif record["uri"] in old: done += 1 else: print(f" {record['uri']} is not in the backup, left alone (rating {rating})") unknown += 1
total = sum(len(records) for records in pending.values()) print(f"{sum(counts.values())} records read: {total} to rewrite, {done} rewritten already, " f"{unknown} left alone, {failed} that can't be rewritten.")
if dry_run or total == 0: return failed
check_backup(backup, pds.did, counts)
written = 0 for collection, records in pending.items(): short = collection.removeprefix(NAMESPACE + ".") for chunk in _chunks(records, MAX_WRITES_PER_CALL): try: pds.apply_updates([ (collection, rkey, {**value, "rating": value["rating"] * FACTOR}) for rkey, value in chunk ]) except XrpcError as error: print(f" {len(chunk)} records of {short} failed: {error}") failed += len(chunk) continue written += len(chunk) print(f" {written}/{total} written ({short})")
print(f"Done: {written} rewritten, {done} already done, {failed} failed.")
return failed
def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--handle", required=True, help="handle (or DID) of the account") parser.add_argument("--pds", default="https://bsky.social", help="URL of the account's PDS") parser.add_argument("--backup", help="ZIP of the app's export from before the change; required to write anything") parser.add_argument("--dry-run", action="store_true", help="only read the PDS and say what would be written") arguments = parser.parse_args()
if not arguments.dry_run and not arguments.backup: sys.exit("Export your data first (Settings -> Account -> Export) and pass the ZIP as --backup. " "Nothing is written without it.")
try: password = os.environ.get("ATPROTO_APP_PASSWORD") or getpass.getpass("App password: ") pds = Pds(arguments.pds, arguments.handle, password) print(f"Logged in as {pds.did}") failed = migrate(pds, arguments.dry_run, arguments.backup) except XrpcError as error: sys.exit(f"The PDS answered with an error: {error}") except KeyboardInterrupt: sys.exit("\nStopped. Run it again with the same backup to carry on - what is rewritten already is skipped.")
if arguments.dry_run: print("Dry run: nothing was written.")
if failed: sys.exit(1)
print("Afterwards use \"Read data again\" in the app's settings, so the index reads the new records.")
if __name__ == "__main__": main()