photolib/photoapp/cli.py

312 lines
11 KiB
Python

import os
import json
import argparse
import requests
from requests.exceptions import HTTPError
from photoapp.utils import get_extension
from photoapp.types import known_extensions, PhotoStatus
from photoapp.common import pwhash
from photoapp.ingest import get_photosets
from urllib.parse import urlparse
from tabulate import tabulate
from concurrent.futures import ThreadPoolExecutor, as_completed
import appdirs
APPNAME = "photocli"
DEFAULT_CONFIG = {"uri": None,
"lastbatch": None}
class CliConfig(dict):
def __init__(self, appname, default):
self.confdir = appdirs.user_config_dir(appname)
self.confpath = os.path.join(self.confdir, "cli.json")
super().__init__(**default)
self.init_config()
def init_config(self):
os.makedirs(self.confdir, exist_ok=True)
if os.path.exists(self.confpath):
self.load()
else:
self.write()
def load(self):
with open(self.confpath) as f:
self.update(**json.load(f))
def write(self):
with open(self.confpath, "w") as f:
json.dump(self, f, indent=4)
def __setitem__(self, key, value):
if key not in self:
raise KeyError(key)
super().__setitem__(key, value)
self.write()
class PhotoApiClient(object):
def __init__(self, base_url, passwd=None):
self.session = requests.Session()
if passwd:
self.session.auth = passwd # user, pass tuple
self.base_url = base_url
def byhash(self, sha):
return self.get("byhash", params={"sha": sha}).json()
def get(self, url, **params):
return self.do("get", url, **params)
def post(self, url, **params):
return self.do("post", url, **params)
def delete(self, url, **params):
return self.do("delete", url, **params)
def do(self, method, url, **kwargs):
resp = getattr(self.session, method)(self.base_url + "/api/v1/" + url, **kwargs)
resp.raise_for_status()
return resp
def create_user(self, username, password):
return self.post("user", data={"username": username,
"password_hash": pwhash(password)})
def list_users(self):
return self.get("user")
def delete_user(self, username):
return self.delete("user", params={"username": username})
def upload(self, files, metadata):
return self.post("upload", files=files, data={"meta": json.dumps(metadata)})
def stats(self):
return self.get("stats").json()
def list_photos(self, page=0, pagesize=25):
return self.get("photos", params={"page": page, "pagesize": pagesize}).json()
def maybetruncate(s, length):
if s and len(s) > length:
s = s[0:length - 3].strip() + "..."
return s
def confirm(message, options=None, accept=None, confirm_msg="Are you sure?"):
if options is None:
options = {"Y": True, "n": False}
if accept is None:
accept = [True]
def show_prompt(message):
result = input("{} [{}]: ".format(message, '/'.join(options.keys())))
try:
result_value = options[result]
if result_value not in accept:
raise Exception("Aborted per request")
return result_value
except KeyError:
raise Exception("Invalid choice")
result = show_prompt(message)
return result
def get_args():
parser = argparse.ArgumentParser(description="photo library cli")
# TODO nicer uri parser
parser.add_argument("--host", default=os.environ.get("PHOTOLIB_URI", None), help="photo api server address")
parser.add_argument("-y", "--yes", action="store_true", help="assume yes for all prompts")
sp_action = parser.add_subparsers(dest="action", help="action to take")
p_dupes = sp_action.add_parser("checkdupes", help="check if files/hash lists are already in the library")
p_dupes.add_argument("--sha-files", action="store_true", help="read hashes from a file instead of hashing images")
p_dupes.add_argument("files", nargs="+", help="files to check")
p_ingest = sp_action.add_parser("ingest", help="import images into the library")
p_ingest.add_argument("-c", "--copy-of", help="existing uuid the imported images will be placed under")
p_ingest.add_argument("-w", "--workers", default=1, type=int, help="number of parallel uploads")
p_ingest.add_argument("files", nargs="+", help="files to import")
sp_action.add_parser("stats", help="show library statistics")
p_list = sp_action.add_parser("list", help="list images in library")
p_list.add_argument("-p", "--page", default=0, help="page number")
p_list.add_argument("-z", "--page-size", default=25, help="page size")
p_list.add_argument("-l", "--show-link", action="store_true", help="print urls to each photo")
# User section
p_adduser = sp_action.add_parser("user", help="user manipulation functions")
p_useraction = p_adduser.add_subparsers(dest="action_user", help="action to take")
p_create = p_useraction.add_parser('create', help='create user')
p_create.add_argument("-u", "--username", help="username", required=True)
p_create.add_argument("-p", "--password", help="password", required=True)
p_useraction.add_parser('list', help='list users')
p_delete = p_useraction.add_parser('delete', help='delete users')
p_delete.add_argument("-u", "--username", help="username", required=True)
return parser.parse_args(), parser
def main():
config = CliConfig(APPNAME, DEFAULT_CONFIG)
args, parser = get_args()
uri = urlparse(args.host or config["uri"])
if not uri.netloc:
parser.error("need --host, $PHOTOLIB_URI, or config file")
config["uri"] = uri.geturl()
port = f":{uri.port}" if uri.port else ""
client = PhotoApiClient(f"{uri.scheme}://{uri.hostname}{port}", (uri.username, uri.password, ))
if args.action == "checkdupes":
hashes = {}
if args.sha_files:
# sha file format should look like the output of `find ./source -type f -exec shasum -a 256 {} \;`:
# e931d286a8377ea8f49ee928e793d9e1fd18a25402cac43afdee3eec3b3b18a5 source/2018-12-26 20.54.40.jpg
# 24c1cd20c961839f14d608f5719c4f380af902c4774ddb7cb9ff747f652ca302 source/2018-12-30 22.39.27.jpg
for fname in args.files:
with open(fname) as f:
for line in f.readlines():
if not line:
continue
sha, path = line.split(maxsplit=1)
path = path.rstrip("\n")
if get_extension(path) not in known_extensions:
continue
hashes[sha] = path
else:
raise NotImplementedError("must pass --sha-files for now")
for sha, path in hashes.items():
# http://localhost:8080/api/v1/byhash?sha=afe49172f709725a4503c9219fb4c6a9db8ad0354fc493f2f500269ac6faeaf6
try:
client.byhash(sha)
# if the file is a dupe, do nothing
except HTTPError as he:
# if the file is original, print its path
if he.response.status_code == 404:
print(path)
else:
raise
elif args.action == "ingest":
sets, skipped = get_photosets(args.files)
rows = []
for set_ in sets:
rows.append([" ".join([f.path for f in set_.files]), "upload"])
for fname in skipped:
rows.append([fname, "skip"])
print(tabulate(rows, headers=["files", "action"])) # TODO also dupe check here
if not sets:
return
print()
if not args.yes:
if not confirm("Continue?"):
return
print()
results = []
numerrors = 0
def printprogress():
print(f"\rProgress: total: {len(sets)} completed: {len(results)} errors: {numerrors} ", end="")
def upload_set(set_):
payload = set_.to_json()
payload["files"] = {os.path.basename(photo.path): photo.to_json() for photo in set_.files}
files = []
for file in set_.files:
files.append(("files", (os.path.basename(file.path), open(file.path, 'rb'), file.format), ))
if args.copy_of:
payload["uuid"] = args.copy_of
try:
result = client.upload(files, payload).json()
return ["success", result["uuid"]]
except HTTPError as he:
return ["error", he.response.json()["error"]]
printprogress()
with ThreadPoolExecutor(max_workers=args.workers) as executor:
futures = {executor.submit(upload_set, set_): set_ for set_ in sets}
for future in as_completed(futures.keys()):
set_ = futures[future]
set_fnames = [os.path.basename(file.path) for file in set_.files]
e = future.exception()
if e:
results.append([set_fnames, "exception", repr(e)])
numerrors += 1
else:
result = future.result()
if result[0] != "success":
numerrors += 1
results.append([set_fnames] + result)
printprogress()
print()
print()
print(tabulate([[" ".join(row[0]), row[1], row[2]] for row in results],
headers=["files", "status", "uuid"]))
print("\nErrors:", numerrors)
# TODO be nice and close the files
elif args.action == "list":
headers = ["link" if args.show_link else "uuid",
"status",
"size",
"formats",
"date",
"title"]# TODO tags
rows = []
for set_ in client.list_photos(args.page, args.page_size):
row = [set_["uuid"] if not args.show_link else args.host.rstrip("/") + "/photo/" + set_["uuid"],
PhotoStatus[set_["status"]].name,
sum([p["size"] for p in set_["files"].values()]),
" ".join(set([p["format"] for p in set_["files"].values()])),
set_["date"],
maybetruncate(set_["title"], 24),
]
rows.append(row)
print(tabulate(rows, headers=headers))
elif args.action == "stats":
print(tabulate(sorted([[k, v] for k, v in client.stats().items()],
key=lambda row: row[0]),
headers=["item", "count"]))
elif args.action == "user":
if args.action_user == "create":
print(client.create_user(args.username, args.password).json())
elif args.action_user == "list":
for u in client.list_users().json():
print(f"{u['name']}: {u['status']}")
elif args.action_user == "delete":
print(client.delete_user(args.username).json())
if __name__ == "__main__":
main()