From 722a2f7b2d26004a69207d4f809596c9ad39e8bf Mon Sep 17 00:00:00 2001 From: "B. Stack" Date: Fri, 15 Jul 2022 13:23:41 -0400 Subject: initial commit --- .gitignore | 4 ++ README.md | 50 +++++++++++++++ extra/get-albums.sh | 37 +++++++++++ pp.py | 72 ++++++++++++++++++++++ pplib.py | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 336 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 extra/get-albums.sh create mode 100755 pp.py create mode 100644 pplib.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..520ecc8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +photoprism-pull.sh +__pycache__ +pwfile +get-albums-*.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..5853793 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# README for photoprismpull +This is a basic python lib for interacting with the API of a [PhotoPrism](https://docs.photoprism.app/) instance. + +## Upstream +This project's upstream is at and . + +## Alternatives +A [library written in Go](https://docs.photoprism.app/developer-guide/api/clients/go/) is advertised by Photoprism. + +### Related +For a shell interaction with the API to trigger imports, see . + +For a script to create albums from a Google Photos Takeout in PhotoPrism see . + +## Reason for existing +I do not know Go, but I am comfortable in python. My two original goals for this library were: + +* Download album to directory (with optional limitation such as "only the last 60 days" +* Add photos to album based on sha1sums + +## Using +There is a small front-end, `pp.py`. Run its `--help` option. + +I tend to use this library in an interactive python shell. One task included fetching a Google Photos album as a zip, extracting, and generating a sha1sums file. I had used that related photoprism-transfer-album tool, but my Takeout failed to include a few albums for no reason. Since I already had all the photos in PhotoPrism, I just needed to find them and add them to the correct album. + + sha1sum -- * > input.album5 + +And then in a python shell: + + import importlib, pplib + # as needed after changes: + importlib.reload(pplib) + a = pplib.get_session("https://pp.example.com","admin","password") + c = add_hashes_to_album(a, "Pretty Album Name", "/mnt/public/Images/photoprism/Named albums/Pretty Album Name/input.album5",apply=True) + +The simpler example is downloading an album to a subdirectory. + + import pplib + a = pplib.get_session("https://pp.example.com","admin","password") + c = download_album_to_directory("Pretty Album Name",directory="/mnt/public/Images/photoprism",extraparams="&after=2020-02-08",session=a) + +This will make a new directory, `/mnt/public/Images/photoprism/Pretty Album Name/` and populate it with the images from this named album in PhotoPrism. Note that the `get_album_by_title()` function explicitly excludes "albums" autogenerated from existing directories in your originals directory. It only searches through actual "album" albums. This is trivial to change for yourself as needed. + +## Dependencies +A chart for distros, or maybe just a simple package list. + +## Building +If applicable, or more complicated than `make && make install`. + +## References diff --git a/extra/get-albums.sh b/extra/get-albums.sh new file mode 100755 index 0000000..72b98c4 --- /dev/null +++ b/extra/get-albums.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# File: get-albums.sh +# Location: extra/ +# Author: bgstack15 +# Startdate: 2022-07-07 13:50 +# Title: Demo for getting albums +# Purpose: Download albums easily, but only keep the past so many days +# History: +# Usage: +# adjust variables at top, and album names looped at the bottom. +# Reference: +# Improve: +# switch to bash, and put the list of album names in the top part with the other variables? +# Dependencies: +# Documentation: see README.md for project + +OUTDIR=/mnt/public/pictures +SCRIPT=./pp.py +USERNAME=admin +PWFILE=pwfile +URL=http://vm4:2342 + +get_album(){ + # Goal: get photos from this named album, and keep only ones from under $DAYS days ago. + # call: get_album "${TOPDIR}" "${NAME}" "${DAYS}" + _dir="${1}" + _name="${2}" + _days="${3}" + when="$( date -d "-${_days} days" "+%F" )" + test -d "${_dir}/${_name}" && find "${_dir}/${_name}" -mindepth 1 ! -type d -mtime "+${_days}" -delete + "${SCRIPT}" --url "${URL}" --password "${PWFILE}" --username "${USERNAME}" -a "${_name}" --extra "&after=${when}" --directory "${_dir}" +} + +for album in "Pretty Name 1" "Family Memories 2020-2025" ; +do + get_album "${OUTDIR}" "${album}" "60" +done diff --git a/pp.py b/pp.py new file mode 100755 index 0000000..de3cbbe --- /dev/null +++ b/pp.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# File: pp.py +# Location: photoprismpull/ +# Author: bgstack15 +# SPDX-License-Identifier: GPL-3.0 +# Startdate: 2022-07-07 11:50 +# Title: Photoprism Pull +# Project: photoprismpull +# Purpose: front-end for pplib +# History: +# Usage: +# Reference: +# Improve: +# Dependencies: pplib.py in this project +# Documentation: see README.md +import argparse, pplib, os + +pp_version = "2022-07-08a" + +parser = argparse.ArgumentParser(description="Interact with PhotoPrism API") +parser.add_argument("-p","--password","--pass", required=True, help="password or filename that contains password string.") +parser.add_argument("-u","--username","--user", help="username") +parser.add_argument("-r","--url", required=True, help="top url of PhotoPrism instance, e.g., http://vm4:2342") +action = parser.add_mutually_exclusive_group(required=True) +action.add_argument("-l","--list", action="store_true", help="List albums (titles and image count).") +action.add_argument("-a","--album", action="append", help="Download this album(s) by name. Can be called multiple times.") +parser.add_argument("-V","--version", action="version", version="%(prog)s "+pp_version) +parser.add_argument("-d","--dir","--directory", help="Top path to hold album directories. Default is {os.curdir}.") +parser.add_argument("-e","--extra","--extraparams","--extra-params", help="Additional query params to limit photos search within the album. See ") + +args = parser.parse_args() +#print(args) +username = "" +password = "" +action = "" +directory = "" +extraparams = "" +if os.path.exists(args.password): + with open(args.password,"r") as f: + # Stripping this protects against an innocuous newline in the password file. + password = f.read().rstrip('\n') +#print(f"using password {password}") +if args.list: + action = "list" +else: + action = "album" +url = "" +if "url" in args and args.url is not None: + url = args.url +if "username" in args and args.username is not None: + username = args.username +if "dir" in args and args.dir is not None: + directory = args.dir +else: + directory = os.curdir +if "extra" in args and args.extra is not None: + extraparams = args.extra + +#print(f"Taking action {action} for {url}") +if "list" == action: + s = pplib.get_session(url, username, password) + #print(f"Got session {s}") + albums = pplib.get_albums(s) + #print(albums) + for a in albums: + print(f"{a['PhotoCount']} {a['Title']}") +elif "album" == action: + s = pplib.get_session(url, username, password) + for a in args.album: + _dir = os.path.join(directory,a) + print(f"Fetching album \"{a}\" to \"{_dir}\"") + pplib.download_album_to_directory(a,_dir,extraparams,s) diff --git a/pplib.py b/pplib.py new file mode 100644 index 0000000..7d22038 --- /dev/null +++ b/pplib.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +# File: pplib.py +# Location: photoprismpull/ +# Author: bgstack15 +# SPDX-License-Identifier: GPL-3.0 +# Startdate: 2022-07-06 18:29 +# Title: Photoprism Pull library +# Project: PhotoprismPull +# Purpose: low-level interactions with Photoprism API +# History: +# Usage: +# Either in a python shell or from pp.py +# Reference: +# https://github.com/photoprism/photoprism/blob/develop/internal/form/album.go +# Improve: +# Dependencies: +# python3 +# Documentation: see README.md +import json, requests, shutil, os, datetime, time, hashlib + +MAX_COUNT = 1000 +DLTOKEN = "" # will be populated by get_session + +def get_session(url = "", username = "", password = "", session = None): + """ Return session object with headers that store the X-Session-ID. Additionally, sets global var DLTOKEN which must be passed as a parameter in every download request, f"?t={session.headers['dltoken']}". """ + s = session if isinstance(session,requests.sessions.Session) else requests.session() + #print(f"DEBUG (get_session): username {username} password {password}") + a = s.post(f"{url}/api/v1/session",json={"username": username, "password": password}) + if 200 == a.status_code: + global DLTOKEN + DLTOKEN = json.loads(a.content)['config']['downloadToken'] + s.headers['X-Session-ID'] = a.headers['X-Session-Id'] + s.headers['url'] = url + else: + print(f"Error! Bad response when requesting session: {a.status_code}.") + return s + +def get_albums(session): + s = session + global MAX_COUNT + r = json.loads(s.get(f"{s.headers['url']}/api/v1/albums?count={MAX_COUNT}").content) + try: + r = [f for f in r if f['Type'] == 'album'] + except: + pass + return r + +def get_album_by_title(session,title): + s = session + global MAX_COUNT + r = get_albums(s) + try: + # cheater method to get the one whose attribute is what we want + r = [f for f in r if f['Title'] == title][0] + except: + print(f"Warning! Unable to find an album with title \"{title}\"") + return r + +def get_photos_by_album_uid(session, album_uid, extraparams = ""): + s = session + global MAX_COUNT + r = s.get(f"{s.headers['url']}/api/v1/photos?count={MAX_COUNT}&album={album_uid}{extraparams}") + try: + r = json.loads(r.content) + except: + print(f"Warning! Unable to list photos for album uid \"{album_uid}\".") + return r + +def download_image_to_file(session, _hash, filename, date = None): + """ Given the unique hash to a file, and filename, and optionally a date string (ISO8601), download that file to the provided filename. + Skips the download if the file exists and has the exact same sha1sum (hash). + This is a low-level function that does parse the downloaded filename. You must provide the filename. """ + global DLTOKEN + s = session + # Need to remove the "gzip" from Accept-Encoding so the file is not gzipped. + headers = s.headers + try: + headers.pop('Accept-Encoding') + except: + pass + #print(headers) + have = False + #print(f"Fetching {_hash} as {filename}") + if os.path.exists(filename): + hash = hashlib.sha1() + hash.update(open(filename,"rb").read()) + if hash.hexdigest() == _hash: + # we already have the file + have = True + print(f"Have: {_hash}",end=' ') + if not have: + print(f"Fetching {_hash}",end=' ') + with s.get(f"{s.headers['url']}/api/v1/dl/{_hash}?t={DLTOKEN}", stream=True, headers = headers) as r: + with open(filename,"wb") as f: + shutil.copyfileobj(r.raw, f) + if date: + try: + # Photoprism returns nice ISO8601 dates + date = datetime.datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ") + except: + pass + try: + date = time.mktime(date.timetuple()) + except: + pass + os.utime(filename, (date, date)) + hash = hashlib.sha1() + hash.update(open(filename,"rb").read()) + if hash.hexdigest() != _hash: + # This can happen if the DLTOKEN or session has expired, and we got the 420-byte svg for "invalid" + print(f"\rfailed {_hash}",end=' ') + os.remove(filename) + print(filename) + +def get_image(session, image, directory = None): + """ + Given a dict of an image, such as the one returned from an image search, download the file to a nicely named file. + This is the higher-level function that wraps around download_image_to_file. + """ + if None == directory: + directory = os.curdir + if not os.path.isdir(directory): + try: + os.makedirs(directory) + except: + print(f"Error! Unable to make directory {directory}") + #print(f"download_image_to_file({session},{image['Hash']},{image['FileName'].split('/')[-1]},{directory})") + download_image_to_file(session, image['Hash'], os.path.join(directory,image['FileName'].split('/')[-1]), image['TakenAt']) + +def download_album_to_directory(albumname,directory = ".",extraparams = "", session = None, username = "", password = "", url = ""): + """ Given extraparams (example "&after=2022-07-01") and albumname, download that album to directory. """ + s = session if isinstance(session,requests.sessions.Session) else get_session(url, username, password) + album = get_album_by_title(s, albumname) + photos = get_photos_by_album_uid(s, album_uid = album['UID'], extraparams = extraparams) + if not os.path.isdir(directory): + try: + os.makedirs(directory) + except: + print(f"Error! Unable to make directory {directory}") + for p in photos: + get_image(s, p, directory) + return photos + +def add_hashes_to_album(session, albumname, hashes_file, apply = False): + """ Given album name, and file that contains list of sha1sums of files that should exist already in photoprism, add these files to the album. """ + s = session if isinstance(session,requests.sessions.Session) else get_session(url, username, password) + album = get_album_by_title(s, albumname) + album_uid = album['UID'] + with open(hashes_file,"r") as f: + lines = f.readlines() + x = 0 + for line in lines: + image = None + image_uid = None + line=line.rstrip() + try: + short = line.split(' ')[0].rstrip() + except: + short = line.rstrip() + if short is not None and len(short) > 0 and short.strip() != "": + r = s.get(f"{s.headers['url']}/api/v1/photos?count={MAX_COUNT}&hash={short}") + try: + image = json.loads(r.content)[0] + #print(image) + image_uid = image['UID'] + print(f"Found image for {line}") + if apply: + add = json.loads(s.post(f"{s.headers['url']}/api/v1/albums/{album_uid}/photos",json={'photos':[image_uid]}).content) + print(add) + except: + x = x + 1 + print(f"MISSING: {line}") + return x -- cgit