Restructured entire code base to make it easier to maintain. Closes #16
This commit is contained in:
File diff suppressed because it is too large
Load Diff
17
osxphotos/_constants.py
Normal file
17
osxphotos/_constants.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# which Photos library database versions have been tested
|
||||
# Photos 2.0 (10.12.6) == 2622
|
||||
# Photos 3.0 (10.13.6) == 3301
|
||||
# Photos 4.0 (10.14.5) == 4016
|
||||
# Photos 4.0 (10.14.6) == 4025
|
||||
# Photos 5.0 (10.15.0) == 6000
|
||||
# TODO: Should this also use compatibleBackToVersion from LiGlobals?
|
||||
_TESTED_DB_VERSIONS = ["6000", "4025", "4016", "3301", "2622"]
|
||||
|
||||
# versions later than this have a different database structure
|
||||
_PHOTOS_5_VERSION = "6000"
|
||||
|
||||
# which major version operating systems have been tested
|
||||
_TESTED_OS_VERSIONS = ["12", "13", "14", "15"]
|
||||
|
||||
# Photos 5 has persons who are empty string if unidentified face
|
||||
_UNKNOWN_PERSON = "_UNKNOWN_"
|
||||
@@ -1,4 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.15.01"
|
||||
|
||||
__version__ = "0.16.01"
|
||||
|
||||
@@ -135,13 +135,13 @@ def dump(cli_obj):
|
||||
@click.pass_obj
|
||||
def list_libraries(cli_obj):
|
||||
""" Print list of Photos libraries found on the system. """
|
||||
photo_libs = osxphotos.list_photo_libraries()
|
||||
photo_libs = osxphotos.utils.list_photo_libraries()
|
||||
sys_lib = None
|
||||
_, major, _ = osxphotos._get_os_version()
|
||||
_, major, _ = osxphotos.utils._get_os_version()
|
||||
if int(major) >= 15:
|
||||
sys_lib = osxphotos.get_system_library_path()
|
||||
|
||||
last_lib = osxphotos.get_last_library_path()
|
||||
sys_lib = osxphotos.utils.get_system_library_path()
|
||||
|
||||
last_lib = osxphotos.utils.get_last_library_path()
|
||||
|
||||
last_lib_flag = sys_lib_flag = False
|
||||
|
||||
@@ -162,6 +162,7 @@ def list_libraries(cli_obj):
|
||||
if last_lib_flag:
|
||||
click.echo("(#)\tLast opened Photos Library")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--keyword", default=None, multiple=True, help="Search for keyword(s).")
|
||||
@click.option("--person", default=None, multiple=True, help="Search for person(s).")
|
||||
|
||||
490
osxphotos/photoinfo.py
Normal file
490
osxphotos/photoinfo.py
Normal file
@@ -0,0 +1,490 @@
|
||||
import json
|
||||
import logging
|
||||
import os.path
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from ._constants import _PHOTOS_5_VERSION
|
||||
from .utils import _get_resource_loc, dd_to_dms_str
|
||||
|
||||
|
||||
class PhotoInfo:
|
||||
"""
|
||||
Info about a specific photo, contains all the details about the photo
|
||||
including keywords, persons, albums, uuid, path, etc.
|
||||
"""
|
||||
|
||||
def __init__(self, db=None, uuid=None, info=None):
|
||||
self._uuid = uuid
|
||||
self._info = info
|
||||
self._db = db
|
||||
|
||||
def filename(self):
|
||||
""" filename of the picture """
|
||||
return self._info["filename"]
|
||||
|
||||
def original_filename(self):
|
||||
""" original filename of the picture """
|
||||
""" Photos 5 mangles filenames upon import """
|
||||
return self._info["originalFilename"]
|
||||
|
||||
def date(self):
|
||||
""" image creation date as timezone aware datetime object """
|
||||
imagedate = self._info["imageDate"]
|
||||
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
|
||||
delta = timedelta(seconds=seconds)
|
||||
tz = timezone(delta)
|
||||
imagedate_utc = imagedate.astimezone(tz=tz)
|
||||
return imagedate_utc
|
||||
|
||||
def tzoffset(self):
|
||||
""" timezone offset from UTC in seconds """
|
||||
return self._info["imageTimeZoneOffsetSeconds"]
|
||||
|
||||
def path(self):
|
||||
""" absolute path on disk of the original picture """
|
||||
photopath = ""
|
||||
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
vol = self._info["volume"]
|
||||
if vol is not None:
|
||||
photopath = os.path.join("/Volumes", vol, self._info["imagePath"])
|
||||
else:
|
||||
photopath = os.path.join(
|
||||
self._db._masters_path, self._info["imagePath"]
|
||||
)
|
||||
|
||||
if self._info["isMissing"] == 1:
|
||||
photopath = None # path would be meaningless until downloaded
|
||||
# TODO: Is there a way to use applescript or PhotoKit to force the download in this
|
||||
else:
|
||||
if self._info["masterFingerprint"]:
|
||||
# if masterFingerprint is not null, path appears to be valid
|
||||
if self._info["directory"].startswith("/"):
|
||||
photopath = os.path.join(
|
||||
self._info["directory"], self._info["filename"]
|
||||
)
|
||||
else:
|
||||
photopath = os.path.join(
|
||||
self._db._masters_path,
|
||||
self._info["directory"],
|
||||
self._info["filename"],
|
||||
)
|
||||
else:
|
||||
photopath = None
|
||||
logging.debug(f"WARNING: masterFingerprint null {pformat(self._info)}")
|
||||
|
||||
# TODO: fix the logic for isMissing
|
||||
if self._info["isMissing"] == 1:
|
||||
photopath = None # path would be meaningless until downloaded
|
||||
|
||||
logging.debug(photopath)
|
||||
|
||||
return photopath
|
||||
|
||||
def path_edited(self):
|
||||
""" absolute path on disk of the edited picture """
|
||||
""" None if photo has not been edited """
|
||||
photopath = ""
|
||||
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
if self._info["hasAdjustments"]:
|
||||
edit_id = self._info["edit_resource_id"]
|
||||
if edit_id is not None:
|
||||
library = self._db._library_path
|
||||
folder_id, file_id = _get_resource_loc(edit_id)
|
||||
# todo: is this always true or do we need to search file file_id under folder_id
|
||||
photopath = os.path.join(
|
||||
library,
|
||||
"resources",
|
||||
"media",
|
||||
"version",
|
||||
folder_id,
|
||||
"00",
|
||||
f"fullsizeoutput_{file_id}.jpeg",
|
||||
)
|
||||
if not os.path.isfile(photopath):
|
||||
logging.warning(
|
||||
f"edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
|
||||
)
|
||||
photopath = None
|
||||
else:
|
||||
logging.warning(
|
||||
f"{self.uuid} hasAdjustments but edit_model_id is None"
|
||||
)
|
||||
else:
|
||||
photopath = None
|
||||
|
||||
# if self._info["isMissing"] == 1:
|
||||
# photopath = None # path would be meaningless until downloaded
|
||||
else:
|
||||
# in Photos 5.0 / Catalina / MacOS 10.15:
|
||||
# edited photos appear to always be converted to .jpeg and stored in
|
||||
# library_name/resources/renders/X/UUID_1_201_a.jpeg
|
||||
# where X = first letter of UUID
|
||||
# and UUID = UUID of image
|
||||
# this seems to be true even for photos not copied to Photos library and
|
||||
# where original format was not jpg/jpeg
|
||||
# if more than one edit, previous edit is stored as UUID_p.jpeg
|
||||
|
||||
if self._info["hasAdjustments"]:
|
||||
library = self._db._library_path
|
||||
directory = self._uuid[0] # first char of uuid
|
||||
photopath = os.path.join(
|
||||
library,
|
||||
"resources",
|
||||
"renders",
|
||||
directory,
|
||||
f"{self._uuid}_1_201_a.jpeg",
|
||||
)
|
||||
|
||||
if not os.path.isfile(photopath):
|
||||
logging.warning(
|
||||
f"edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
|
||||
)
|
||||
photopath = None
|
||||
else:
|
||||
photopath = None
|
||||
|
||||
# TODO: might be possible for original/master to be missing but edit to still be there
|
||||
# if self._info["isMissing"] == 1:
|
||||
# photopath = None # path would be meaningless until downloaded
|
||||
|
||||
logging.debug(photopath)
|
||||
|
||||
return photopath
|
||||
|
||||
def description(self):
|
||||
""" long / extended description of picture """
|
||||
return self._info["extendedDescription"]
|
||||
|
||||
def persons(self):
|
||||
""" list of persons in picture """
|
||||
return self._info["persons"]
|
||||
|
||||
def albums(self):
|
||||
""" list of albums picture is contained in """
|
||||
albums = []
|
||||
for album in self._info["albums"]:
|
||||
albums.append(self._db._dbalbum_details[album]["title"])
|
||||
return albums
|
||||
|
||||
def keywords(self):
|
||||
""" list of keywords for picture """
|
||||
return self._info["keywords"]
|
||||
|
||||
def name(self):
|
||||
""" (deprecated) name / title of picture """
|
||||
# TODO: add warning on deprecation
|
||||
return self._info["name"]
|
||||
|
||||
def title(self):
|
||||
""" name / title of picture """
|
||||
# TODO: Update documentation and tests to use title
|
||||
return self._info["name"]
|
||||
|
||||
def uuid(self):
|
||||
""" UUID of picture """
|
||||
return self._uuid
|
||||
|
||||
def ismissing(self):
|
||||
""" returns true if photo is missing from disk (which means it's not been downloaded from iCloud)
|
||||
NOTE: the photos.db database uses an asynchrounous write-ahead log so changes in Photos
|
||||
do not immediately get written to disk. In particular, I've noticed that downloading
|
||||
an image from the cloud does not force the database to be updated until something else
|
||||
e.g. an edit, keyword, etc. occurs forcing a database synch
|
||||
The exact process / timing is a mystery to be but be aware that if some photos were recently
|
||||
downloaded from cloud to local storate their status in the database might still show
|
||||
isMissing = 1
|
||||
"""
|
||||
return True if self._info["isMissing"] == 1 else False
|
||||
|
||||
def hasadjustments(self):
|
||||
""" True if picture has adjustments / edits """
|
||||
return True if self._info["hasAdjustments"] == 1 else False
|
||||
|
||||
def external_edit(self):
|
||||
""" Returns True if picture was edited outside of Photos using external editor """
|
||||
return (
|
||||
True
|
||||
if self._info["adjustmentFormatID"] == "com.apple.Photos.externalEdit"
|
||||
else False
|
||||
)
|
||||
|
||||
def favorite(self):
|
||||
""" True if picture is marked as favorite """
|
||||
return True if self._info["favorite"] == 1 else False
|
||||
|
||||
def hidden(self):
|
||||
""" True if picture is hidden """
|
||||
return True if self._info["hidden"] == 1 else False
|
||||
|
||||
def location(self):
|
||||
""" returns (latitude, longitude) as float in degrees or None """
|
||||
return (self._latitude(), self._longitude())
|
||||
|
||||
def export(
|
||||
self,
|
||||
dest,
|
||||
*filename,
|
||||
edited=False,
|
||||
overwrite=False,
|
||||
increment=True,
|
||||
sidecar=False,
|
||||
):
|
||||
""" export photo """
|
||||
""" first argument must be valid destination path (or exception raised) """
|
||||
""" second argument (optional): name of picture; if not provided, will use current filename """
|
||||
""" if edited=True (default=False), will export the edited version of the photo (or raise exception if no edited version) """
|
||||
""" if overwrite=True (default=False), will overwrite files if they alreay exist """
|
||||
""" if increment=True (default=True), will increment file name until a non-existant name is found """
|
||||
""" if overwrite=False and increment=False, export will fail if destination file already exists """
|
||||
""" if sidecar=True, will also write a json sidecar with EXIF data in format readable by exiftool """
|
||||
""" sidecar filename will be dest/filename.ext.json where ext is suffix of the image file (e.g. jpeg or jpg) """
|
||||
""" returns the full path to the exported file """
|
||||
|
||||
# TODO: add this docs:
|
||||
# ( for jpeg in *.jpeg; do exiftool -v -json=$jpeg.json $jpeg; done )
|
||||
|
||||
# check arguments and get destination path and filename (if provided)
|
||||
if filename and len(filename) > 2:
|
||||
raise TypeError(
|
||||
"Too many positional arguments. Should be at most two: destination, filename."
|
||||
)
|
||||
else:
|
||||
# verify destination is a valid path
|
||||
if dest is None:
|
||||
raise ValueError("Destination must not be None")
|
||||
elif not os.path.isdir(dest):
|
||||
raise FileNotFoundError("Invalid path passed to export")
|
||||
|
||||
if filename and len(filename) == 1:
|
||||
# second arg is filename of picture
|
||||
filename = filename[0]
|
||||
else:
|
||||
# no filename provided so use the default
|
||||
# if edited file requested, use filename but add _edited
|
||||
# need to use file extension from edited file as Photos saves a jpeg once edited
|
||||
if edited:
|
||||
# verify we have a valid path_edited and use that to get filename
|
||||
if not self.path_edited():
|
||||
raise FileNotFoundError(
|
||||
f"edited=True but path_edited is none; hasadjustments: {self.hasadjustments()}"
|
||||
)
|
||||
edited_name = Path(self.path_edited()).name
|
||||
edited_suffix = Path(edited_name).suffix
|
||||
filename = Path(self.filename()).stem + "_edited" + edited_suffix
|
||||
else:
|
||||
filename = self.filename()
|
||||
|
||||
# get path to source file and verify it's not None and is valid file
|
||||
# TODO: how to handle ismissing or not hasadjustments and edited=True cases?
|
||||
if edited:
|
||||
if not self.hasadjustments():
|
||||
logging.warning(
|
||||
"Attempting to export edited photo but hasadjustments=False"
|
||||
)
|
||||
|
||||
if self.path_edited() is not None:
|
||||
src = self.path_edited()
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"edited=True but path_edited is none; hasadjustments: {self.hasadjustments()}"
|
||||
)
|
||||
else:
|
||||
if self.ismissing():
|
||||
logging.warning(
|
||||
f"Attempting to export photo with ismissing=True: path = {self.path()}"
|
||||
)
|
||||
|
||||
if self.path() is None:
|
||||
logging.warning(
|
||||
f"Attempting to export photo but path is None: ismissing = {self.ismissing()}"
|
||||
)
|
||||
raise FileNotFoundError("Cannot export photo if path is None")
|
||||
else:
|
||||
src = self.path()
|
||||
|
||||
if not os.path.isfile(src):
|
||||
raise FileNotFoundError(f"{src} does not appear to exist")
|
||||
|
||||
dest = pathlib.Path(dest)
|
||||
filename = pathlib.Path(filename)
|
||||
dest = dest / filename
|
||||
|
||||
# check to see if file exists and if so, add (1), (2), etc until we find one that works
|
||||
if increment and not overwrite:
|
||||
count = 1
|
||||
dest_new = dest
|
||||
while dest_new.exists():
|
||||
dest_new = dest.parent / f"{dest.stem} ({count}){dest.suffix}"
|
||||
count += 1
|
||||
dest = dest_new
|
||||
|
||||
logging.debug(
|
||||
f"exporting {src} to {dest}, overwrite={overwrite}, incremetn={increment}, dest exists: {dest.exists()}"
|
||||
)
|
||||
|
||||
# if overwrite==False and #increment==False, export should fail if file exists
|
||||
if dest.exists() and not overwrite and not increment:
|
||||
raise FileExistsError(
|
||||
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
|
||||
)
|
||||
|
||||
# if error on copy, subprocess will raise CalledProcessError
|
||||
try:
|
||||
subprocess.run(
|
||||
["/usr/bin/ditto", src, dest], check=True, stderr=subprocess.PIPE
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.critical(
|
||||
f"ditto returned error: {e.returncode} {e.stderr.decode(sys.getfilesystemencoding()).rstrip()}"
|
||||
)
|
||||
raise e
|
||||
|
||||
if sidecar:
|
||||
logging.debug("writing exiftool_json_sidecar")
|
||||
sidecar_filename = f"{dest}.json"
|
||||
json_sidecar_str = self._exiftool_json_sidecar()
|
||||
try:
|
||||
self._write_sidecar_car(sidecar_filename, json_sidecar_str)
|
||||
except Exception as e:
|
||||
logging.critical(f"Error writing json sidecar to {sidecar_filename}")
|
||||
raise e
|
||||
|
||||
return str(dest)
|
||||
|
||||
def _exiftool_json_sidecar(self):
|
||||
""" return json string of EXIF details in exiftool sidecar format """
|
||||
exif = {}
|
||||
exif["FileName"] = self.filename()
|
||||
|
||||
if self.description():
|
||||
exif["ImageDescription"] = self.description()
|
||||
exif["Description"] = self.description()
|
||||
|
||||
if self.title():
|
||||
exif["Title"] = self.title()
|
||||
|
||||
if self.keywords():
|
||||
exif["TagsList"] = exif["Keywords"] = self.keywords()
|
||||
|
||||
if self.persons():
|
||||
exif["PersonInImage"] = self.persons()
|
||||
|
||||
# if self.favorite():
|
||||
# exif["Rating"] = 5
|
||||
|
||||
(lat, lon) = self.location()
|
||||
if lat is not None and lon is not None:
|
||||
lat_str, lon_str = dd_to_dms_str(lat, lon)
|
||||
exif["GPSLatitude"] = lat_str
|
||||
exif["GPSLongitude"] = lon_str
|
||||
exif["GPSPosition"] = f"{lat_str}, {lon_str}"
|
||||
lat_ref = "North" if lat >= 0 else "South"
|
||||
lon_ref = "East" if lon >= 0 else "West"
|
||||
exif["GPSLatitudeRef"] = lat_ref
|
||||
exif["GPSLongitudeRef"] = lon_ref
|
||||
|
||||
# process date/time and timezone offset
|
||||
date = self.date()
|
||||
# exiftool expects format to "2015:01:18 12:00:00"
|
||||
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
|
||||
offsettime = date.strftime("%z")
|
||||
# find timezone offset in format "-04:00"
|
||||
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
|
||||
offset = offset[0] # findall returns list of tuples
|
||||
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
|
||||
exif["DateTimeOriginal"] = datetimeoriginal
|
||||
exif["OffsetTimeOriginal"] = offsettime
|
||||
|
||||
json_str = json.dumps([exif])
|
||||
return json_str
|
||||
|
||||
def _write_sidecar_car(self, filename, json_str):
|
||||
if not filename and not json_str:
|
||||
raise (
|
||||
ValueError(
|
||||
f"filename {filename} and json_str {json_str} must not be None"
|
||||
)
|
||||
)
|
||||
|
||||
# TODO: catch exception?
|
||||
f = open(filename, "w")
|
||||
f.write(json_str)
|
||||
f.close()
|
||||
|
||||
def _longitude(self):
|
||||
""" Returns longitude, in degrees """
|
||||
return self._info["longitude"]
|
||||
|
||||
def _latitude(self):
|
||||
""" Returns latitude, in degrees """
|
||||
return self._info["latitude"]
|
||||
|
||||
def __repr__(self):
|
||||
# TODO: update to use __class__ and __name__
|
||||
return f"osxphotos.PhotoInfo(db={self._db}, uuid='{self._uuid}', info={self._info})"
|
||||
|
||||
def __str__(self):
|
||||
info = {
|
||||
"uuid": self.uuid(),
|
||||
"filename": self.filename(),
|
||||
"original_filename": self.original_filename(),
|
||||
"date": str(self.date()),
|
||||
"description": self.description(),
|
||||
"name": self.name(),
|
||||
"keywords": self.keywords(),
|
||||
"albums": self.albums(),
|
||||
"persons": self.persons(),
|
||||
"path": self.path(),
|
||||
"ismissing": self.ismissing(),
|
||||
"hasadjustments": self.hasadjustments(),
|
||||
"external_edit": self.external_edit(),
|
||||
"favorite": self.favorite(),
|
||||
"hidden": self.hidden(),
|
||||
"latitude": self._latitude(),
|
||||
"longitude": self._longitude(),
|
||||
"path_edited": self.path_edited(),
|
||||
}
|
||||
return yaml.dump(info, sort_keys=False)
|
||||
|
||||
def to_json(self):
|
||||
""" return JSON representation """
|
||||
# TODO: Add additional details here
|
||||
pic = {
|
||||
"uuid": self.uuid(),
|
||||
"filename": self.filename(),
|
||||
"original_filename": self.original_filename(),
|
||||
"date": str(self.date()),
|
||||
"description": self.description(),
|
||||
"name": self.name(),
|
||||
"keywords": self.keywords(),
|
||||
"albums": self.albums(),
|
||||
"persons": self.persons(),
|
||||
"path": self.path(),
|
||||
"ismissing": self.ismissing(),
|
||||
"hasadjustments": self.hasadjustments(),
|
||||
"external_edit": self.external_edit(),
|
||||
"favorite": self.favorite(),
|
||||
"hidden": self.hidden(),
|
||||
"latitude": self._latitude(),
|
||||
"longitude": self._longitude(),
|
||||
"path_edited": self.path_edited(),
|
||||
}
|
||||
return json.dumps(pic)
|
||||
|
||||
# compare two PhotoInfo objects for equality
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, self.__class__):
|
||||
return self.__dict__ == other.__dict__
|
||||
else:
|
||||
return False
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
1068
osxphotos/photosdb.py
Normal file
1068
osxphotos/photosdb.py
Normal file
File diff suppressed because it is too large
Load Diff
207
osxphotos/utils.py
Normal file
207
osxphotos/utils.py
Normal file
@@ -0,0 +1,207 @@
|
||||
import glob
|
||||
import logging
|
||||
import os.path
|
||||
import platform
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from plistlib import load as plistload
|
||||
|
||||
import CoreFoundation
|
||||
import objc
|
||||
from Foundation import *
|
||||
|
||||
|
||||
def _get_os_version():
|
||||
# returns tuple containing OS version
|
||||
# e.g. 10.13.6 = (10, 13, 6)
|
||||
version = platform.mac_ver()[0].split(".")
|
||||
if len(version) == 2:
|
||||
(ver, major) = version
|
||||
minor = "0"
|
||||
elif len(version) == 3:
|
||||
(ver, major, minor) = version
|
||||
else:
|
||||
raise (
|
||||
ValueError(
|
||||
f"Could not parse version string: {platform.mac_ver()} {version}"
|
||||
)
|
||||
)
|
||||
return (ver, major, minor)
|
||||
|
||||
|
||||
def _check_file_exists(filename):
|
||||
""" returns true if file exists and is not a directory
|
||||
otherwise returns false """
|
||||
filename = os.path.abspath(filename)
|
||||
return os.path.exists(filename) and not os.path.isdir(filename)
|
||||
|
||||
|
||||
def _get_resource_loc(model_id):
|
||||
""" returns folder_id and file_id needed to find location of edited photo """
|
||||
""" and live photos for version <= Photos 4.0 """
|
||||
# determine folder where Photos stores edited version
|
||||
# edited images are stored in:
|
||||
# Photos Library.photoslibrary/resources/media/version/XX/00/fullsizeoutput_Y.jpeg
|
||||
# where XX and Y are computed based on RKModelResources.modelId
|
||||
|
||||
# file_id (Y in above example) is hex representation of model_id without leading 0x
|
||||
file_id = hex_id = hex(model_id)[2:]
|
||||
|
||||
# folder_id (XX) in above example if first two chars of model_id converted to hex
|
||||
# and left padded with zeros if < 4 digits
|
||||
folder_id = hex_id.zfill(4)[0:2]
|
||||
|
||||
return folder_id, file_id
|
||||
|
||||
|
||||
def _dd_to_dms(dd):
|
||||
""" convert lat or lon in decimal degrees (dd) to degrees, minutes, seconds """
|
||||
""" return tuple of int(deg), int(min), float(sec) """
|
||||
dd = float(dd)
|
||||
negative = dd < 0
|
||||
dd = abs(dd)
|
||||
min_, sec_ = divmod(dd * 3600, 60)
|
||||
deg_, min_ = divmod(min_, 60)
|
||||
if negative:
|
||||
if deg_ > 0:
|
||||
deg_ = deg_ * -1
|
||||
elif min_ > 0:
|
||||
min_ = min_ * -1
|
||||
else:
|
||||
sec_ = sec_ * -1
|
||||
|
||||
return int(deg_), int(min_), sec_
|
||||
|
||||
|
||||
def dd_to_dms_str(lat, lon):
|
||||
""" convert latitude, longitude in degrees to degrees, minutes, seconds as string """
|
||||
""" lat: latitude in degrees """
|
||||
""" lon: longitude in degrees """
|
||||
""" returns: string tuple in format ("51 deg 30' 12.86\" N", "0 deg 7' 54.50\" W") """
|
||||
""" this is the same format used by exiftool's json format """
|
||||
# TODO: add this to readme
|
||||
|
||||
lat_deg, lat_min, lat_sec = _dd_to_dms(lat)
|
||||
lon_deg, lon_min, lon_sec = _dd_to_dms(lon)
|
||||
|
||||
lat_hemisphere = "N"
|
||||
if any([lat_deg < 0, lat_min < 0, lat_sec < 0]):
|
||||
lat_hemisphere = "S"
|
||||
|
||||
lon_hemisphere = "E"
|
||||
if any([lon_deg < 0, lon_min < 0, lon_sec < 0]):
|
||||
lon_hemisphere = "W"
|
||||
|
||||
lat_str = (
|
||||
f"{abs(lat_deg)} deg {abs(lat_min)}' {abs(lat_sec):.2f}\" {lat_hemisphere}"
|
||||
)
|
||||
lon_str = (
|
||||
f"{abs(lon_deg)} deg {abs(lon_min)}' {abs(lon_sec):.2f}\" {lon_hemisphere}"
|
||||
)
|
||||
|
||||
return lat_str, lon_str
|
||||
|
||||
|
||||
def get_system_library_path():
|
||||
""" return the path to the system Photos library as string """
|
||||
""" only works on MacOS 10.15+ """
|
||||
""" on earlier versions, will raise exception """
|
||||
_, major, _ = _get_os_version()
|
||||
if int(major) < 15:
|
||||
raise Exception(
|
||||
"get_system_library_path not implemented for MacOS < 10.15", major
|
||||
)
|
||||
|
||||
plist_file = Path(
|
||||
str(Path.home())
|
||||
+ "/Library/Containers/com.apple.photolibraryd/Data/Library/Preferences/com.apple.photolibraryd.plist"
|
||||
)
|
||||
if plist_file.is_file():
|
||||
with open(plist_file, "rb") as fp:
|
||||
pl = plistload(fp)
|
||||
else:
|
||||
logging.warning(f"could not find plist file: {str(plist_file)}")
|
||||
return None
|
||||
|
||||
photospath = pl["SystemLibraryPath"]
|
||||
|
||||
if photospath is not None:
|
||||
return photospath
|
||||
else:
|
||||
logging.warning("Could not get path to Photos database")
|
||||
return None
|
||||
|
||||
|
||||
def get_last_library_path():
|
||||
""" return the path to the last opened Photos library """
|
||||
# TODO: Need a module level method for this and another PhotosDB method to get current library path
|
||||
plist_file = Path(
|
||||
str(Path.home())
|
||||
+ "/Library/Containers/com.apple.Photos/Data/Library/Preferences/com.apple.Photos.plist"
|
||||
)
|
||||
if plist_file.is_file():
|
||||
with open(plist_file, "rb") as fp:
|
||||
pl = plistload(fp)
|
||||
else:
|
||||
logging.warning(f"could not find plist file: {str(plist_file)}")
|
||||
return None
|
||||
|
||||
# get the IPXDefaultLibraryURLBookmark from com.apple.Photos.plist
|
||||
# this is a serialized CFData object
|
||||
photosurlref = pl["IPXDefaultLibraryURLBookmark"]
|
||||
|
||||
if photosurlref is not None:
|
||||
# use CFURLCreateByResolvingBookmarkData to de-serialize bookmark data into a CFURLRef
|
||||
photosurl = CoreFoundation.CFURLCreateByResolvingBookmarkData(
|
||||
kCFAllocatorDefault, photosurlref, 0, None, None, None, None
|
||||
)
|
||||
|
||||
# the CFURLRef we got is a sruct that python treats as an array
|
||||
# I'd like to pass this to CFURLGetFileSystemRepresentation to get the path but
|
||||
# CFURLGetFileSystemRepresentation barfs when it gets an array from python instead of expected struct
|
||||
# first element is the path string in form:
|
||||
# file:///Users/username/Pictures/Photos%20Library.photoslibrary/
|
||||
photosurlstr = photosurl[0].absoluteString() if photosurl[0] else None
|
||||
|
||||
# now coerce the file URI back into an OS path
|
||||
# surely there must be a better way
|
||||
if photosurlstr is not None:
|
||||
photospath = os.path.normpath(
|
||||
urllib.parse.unquote(urllib.parse.urlparse(photosurlstr).path)
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
"Could not extract photos URL String from IPXDefaultLibraryURLBookmark"
|
||||
)
|
||||
return None
|
||||
|
||||
return photospath
|
||||
else:
|
||||
logging.warning("Could not get path to Photos database")
|
||||
return None
|
||||
|
||||
|
||||
def list_photo_libraries():
|
||||
""" returns list of Photos libraries found on the system """
|
||||
""" on MacOS < 10.15, this may omit some libraries """
|
||||
|
||||
# On 10.15, mdfind appears to find all libraries
|
||||
# On older MacOS versions, mdfind appears to ignore some libraries
|
||||
# glob to find libraries in ~/Pictures then mdfind to find all the others
|
||||
# TODO: make this more robust
|
||||
lib_list = glob.glob(f"{str(Path.home())}/Pictures/*.photoslibrary")
|
||||
|
||||
# On older OS, may not get all libraries so make sure we get the last one
|
||||
last_lib = get_last_library_path()
|
||||
if last_lib:
|
||||
lib_list.append(last_lib)
|
||||
|
||||
output = subprocess.check_output(
|
||||
["/usr/bin/mdfind", "-onlyin", "/", "-name", ".photoslibrary"]
|
||||
).splitlines()
|
||||
for lib in output:
|
||||
lib_list.append(lib.decode("utf-8"))
|
||||
lib_list = list(set(lib_list))
|
||||
lib_list.sort()
|
||||
return lib_list
|
||||
@@ -50,8 +50,8 @@ def test_db_version():
|
||||
def test_os_version():
|
||||
import osxphotos
|
||||
|
||||
(_, major, _) = osxphotos._get_os_version()
|
||||
assert major in osxphotos._TESTED_OS_VERSIONS
|
||||
(_, major, _) = osxphotos.utils._get_os_version()
|
||||
assert major in osxphotos._constants._TESTED_OS_VERSIONS
|
||||
|
||||
|
||||
def test_persons():
|
||||
@@ -170,4 +170,3 @@ def test_keyword_not_in_album():
|
||||
photos3 = [p for p in photos2 if p not in photos1]
|
||||
assert len(photos3) == 1
|
||||
assert photos3[0].uuid() == "Pj99JmYjQkeezdY2OFuSaw"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from osxphotos import _UNKNOWN_PERSON
|
||||
from osxphotos._constants import _UNKNOWN_PERSON
|
||||
|
||||
# TODO: put some of this code into a pre-function
|
||||
|
||||
@@ -109,7 +109,8 @@ def test_init5():
|
||||
def bad_library():
|
||||
return None
|
||||
|
||||
osxphotos.get_last_library_path = bad_library
|
||||
# force get_last_library to return a bad path for testing
|
||||
osxphotos.photosdb.get_last_library_path = bad_library
|
||||
|
||||
with pytest.raises(Exception):
|
||||
assert osxphotos.PhotosDB()
|
||||
@@ -126,8 +127,8 @@ def test_db_version():
|
||||
def test_os_version():
|
||||
import osxphotos
|
||||
|
||||
(_, major, _) = osxphotos._get_os_version()
|
||||
assert major in osxphotos._TESTED_OS_VERSIONS
|
||||
(_, major, _) = osxphotos.utils._get_os_version()
|
||||
assert major in osxphotos._constants._TESTED_OS_VERSIONS
|
||||
|
||||
|
||||
def test_persons():
|
||||
@@ -735,4 +736,3 @@ def test_export_13():
|
||||
with pytest.raises(Exception) as e:
|
||||
assert photos[0].export(dest)
|
||||
assert e.type == type(FileNotFoundError())
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from osxphotos import _UNKNOWN_PERSON
|
||||
from osxphotos._constants import _UNKNOWN_PERSON
|
||||
from osxphotos.utils import dd_to_dms_str
|
||||
|
||||
# TODO: put some of this code into a pre-function
|
||||
|
||||
@@ -396,7 +397,7 @@ def test_export_13():
|
||||
def test_dd_to_dms_str_1():
|
||||
import osxphotos
|
||||
|
||||
lat_str, lon_str = osxphotos.dd_to_dms_str(
|
||||
lat_str, lon_str = dd_to_dms_str(
|
||||
34.559331096, 69.206499174
|
||||
) # Kabul, 34°33'33.59" N 69°12'23.40" E
|
||||
|
||||
@@ -407,7 +408,7 @@ def test_dd_to_dms_str_1():
|
||||
def test_dd_to_dms_str_2():
|
||||
import osxphotos
|
||||
|
||||
lat_str, lon_str = osxphotos.dd_to_dms_str(
|
||||
lat_str, lon_str = dd_to_dms_str(
|
||||
-34.601997592, -58.375665164
|
||||
) # Buenos Aires, 34°36'7.19" S 58°22'32.39" W
|
||||
|
||||
@@ -418,7 +419,7 @@ def test_dd_to_dms_str_2():
|
||||
def test_dd_to_dms_str_3():
|
||||
import osxphotos
|
||||
|
||||
lat_str, lon_str = osxphotos.dd_to_dms_str(
|
||||
lat_str, lon_str = dd_to_dms_str(
|
||||
-1.2666656, 36.7999968
|
||||
) # Nairobi, 1°15'60.00" S 36°47'59.99" E
|
||||
|
||||
@@ -429,7 +430,7 @@ def test_dd_to_dms_str_3():
|
||||
def test_dd_to_dms_str_4():
|
||||
import osxphotos
|
||||
|
||||
lat_str, lon_str = osxphotos.dd_to_dms_str(
|
||||
lat_str, lon_str = dd_to_dms_str(
|
||||
38.889248, -77.050636
|
||||
) # DC: 38° 53' 21.2928" N, 77° 3' 2.2896" W
|
||||
|
||||
@@ -469,4 +470,3 @@ def test_exiftool_json_sidecar():
|
||||
assert item[0][1] == item[1][1]
|
||||
|
||||
# assert sorted(json_got[0].items()) == sorted(json_expected[0].items())
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from osxphotos import _UNKNOWN_PERSON
|
||||
from osxphotos._constants import _UNKNOWN_PERSON
|
||||
|
||||
# TODO: put some of this code into a pre-function
|
||||
|
||||
@@ -44,6 +44,7 @@ UUID_DICT = {
|
||||
"location": "3Jn73XpSQQCluzRBMWRsMA",
|
||||
}
|
||||
|
||||
|
||||
def test_export_1():
|
||||
# test basic export
|
||||
# get an unedited image and export it using default filename
|
||||
@@ -409,5 +410,3 @@ def test_exiftool_json_sidecar():
|
||||
assert sorted(item[0][1]) == sorted(item[1][1])
|
||||
else:
|
||||
assert item[0][1] == item[1][1]
|
||||
|
||||
# assert sorted(json_got[0].items()) == sorted(json_expected[0].items())
|
||||
|
||||
@@ -49,8 +49,8 @@ def test_db_version():
|
||||
def test_os_version():
|
||||
import osxphotos
|
||||
|
||||
(_, major, _) = osxphotos._get_os_version()
|
||||
assert major in osxphotos._TESTED_OS_VERSIONS
|
||||
(_, major, _) = osxphotos.utils._get_os_version()
|
||||
assert major in osxphotos._constants._TESTED_OS_VERSIONS
|
||||
|
||||
|
||||
def test_persons():
|
||||
@@ -169,4 +169,3 @@ def test_keyword_not_in_album():
|
||||
photos3 = [p for p in photos2 if p not in photos1]
|
||||
assert len(photos3) == 1
|
||||
assert photos3[0].uuid() == "6iAZJP7ZQ5iXxapoJb3ytA"
|
||||
|
||||
|
||||
@@ -49,8 +49,8 @@ def test_db_version():
|
||||
def test_os_version():
|
||||
import osxphotos
|
||||
|
||||
(_, major, _) = osxphotos._get_os_version()
|
||||
assert major in osxphotos._TESTED_OS_VERSIONS
|
||||
(_, major, _) = osxphotos.utils._get_os_version()
|
||||
assert major in osxphotos._constants._TESTED_OS_VERSIONS
|
||||
|
||||
|
||||
def test_persons():
|
||||
@@ -169,4 +169,3 @@ def test_keyword_not_in_album():
|
||||
photos3 = [p for p in photos2 if p not in photos1]
|
||||
assert len(photos3) == 1
|
||||
assert photos3[0].uuid() == "od0fmC7NQx+ayVr+%i06XA"
|
||||
|
||||
|
||||
@@ -45,15 +45,15 @@ def test_db_version():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
assert photosdb.get_db_version() in osxphotos._TESTED_DB_VERSIONS
|
||||
assert photosdb.get_db_version() in osxphotos._constants._TESTED_DB_VERSIONS
|
||||
assert photosdb.get_db_version() == "4025"
|
||||
|
||||
|
||||
def test_os_version():
|
||||
import osxphotos
|
||||
|
||||
(_, major, _) = osxphotos._get_os_version()
|
||||
assert major in osxphotos._TESTED_OS_VERSIONS
|
||||
(_, major, _) = osxphotos.utils._get_os_version()
|
||||
assert major in osxphotos._constants._TESTED_OS_VERSIONS
|
||||
|
||||
|
||||
def test_persons():
|
||||
@@ -324,4 +324,3 @@ def test_get_library_path():
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
lib_path = photosdb.get_library_path()
|
||||
assert lib_path.endswith(PHOTOS_LIBRARY_PATH)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user