aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorB. Stack <bgstack15@gmail.com>2022-07-15 13:23:41 -0400
committerB. Stack <bgstack15@gmail.com>2022-07-15 13:23:41 -0400
commit722a2f7b2d26004a69207d4f809596c9ad39e8bf (patch)
tree2e7bead2204c796684f097ba774e95f9b8b3e4a6
downloadphotoprismpull-722a2f7b2d26004a69207d4f809596c9ad39e8bf.tar.gz
photoprismpull-722a2f7b2d26004a69207d4f809596c9ad39e8bf.tar.bz2
photoprismpull-722a2f7b2d26004a69207d4f809596c9ad39e8bf.zip
initial commit
-rw-r--r--.gitignore4
-rw-r--r--README.md50
-rwxr-xr-xextra/get-albums.sh37
-rwxr-xr-xpp.py72
-rw-r--r--pplib.py173
5 files changed, 336 insertions, 0 deletions
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 <https://bgstack15.ddns.net/cgit/photoprismpull> and <https://gitlab.com/bgstack15/photoprismpull>.
+
+## 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 <https://bgstack15.ddns.net/cgit/photoprism-autoimport/>.
+
+For a script to create albums from a Google Photos Takeout in PhotoPrism see <https://github.com/inthreedee/photoprism-transfer-album>.
+
+## 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 <https://github.com/photoprism/photoprism/blob/develop/internal/form/search_photos.go>")
+
+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
bgstack15