Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2d56a7f71 | ||
|
|
b4897ff1b5 | ||
|
|
661a573bf5 | ||
|
|
0c9bd87602 | ||
|
|
896d888710 | ||
|
|
76aee7f189 | ||
|
|
147b30f973 | ||
|
|
a73dc72558 | ||
|
|
c99cf5518d | ||
|
|
1391675a3a | ||
|
|
a3b2784f31 | ||
|
|
cbe79ee98c | ||
|
|
eb7a2988bf |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file. Dates are d
|
||||
|
||||
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
|
||||
#### [v0.44.4](https://github.com/RhetTbull/osxphotos/compare/v0.44.3...v0.44.4)
|
||||
|
||||
> 4 January 2022
|
||||
|
||||
- Refactored photoinfo, photoexporter; #462 [`a73dc72`](https://github.com/RhetTbull/osxphotos/commit/a73dc72558b77152f4c90f143b6a60924b8905c8)
|
||||
- More refactoring of export code, #462 [`147b30f`](https://github.com/RhetTbull/osxphotos/commit/147b30f97308db65868dc7a8d177d77ad0d0ad40)
|
||||
- Export DB can now reside outside export directory, #568 [`76aee7f`](https://github.com/RhetTbull/osxphotos/commit/76aee7f189b4b32e2e263a4e798711713ed17a14)
|
||||
|
||||
#### [v0.44.3](https://github.com/RhetTbull/osxphotos/compare/v0.44.2...v0.44.3)
|
||||
|
||||
> 31 December 2021
|
||||
|
||||
- ImageConverter now uses generic context; #562 [`a3b2784`](https://github.com/RhetTbull/osxphotos/commit/a3b2784f3177a753b78965b8ca205ca9bbb08168)
|
||||
- Updated tests and docs [`1391675`](https://github.com/RhetTbull/osxphotos/commit/1391675a3a45be0d6800a68c8bcc6d0d55d1ab7a)
|
||||
- Updated docs [skip ci] [`cbe79ee`](https://github.com/RhetTbull/osxphotos/commit/cbe79ee98cae68e0789df275220f5a5870a8bd91)
|
||||
|
||||
#### [v0.44.2](https://github.com/RhetTbull/osxphotos/compare/v0.44.1...v0.44.2)
|
||||
|
||||
> 31 December 2021
|
||||
|
||||
- Bug fix for #559 [`42426b9`](https://github.com/RhetTbull/osxphotos/commit/42426b95ee786b2d53482d3d931a0b962a4db20d)
|
||||
|
||||
#### [v0.44.1](https://github.com/RhetTbull/osxphotos/compare/v0.44.0...v0.44.1)
|
||||
|
||||
> 31 December 2021
|
||||
|
||||
11
README.md
11
README.md
@@ -1154,14 +1154,13 @@ Options:
|
||||
You can run more than one function by
|
||||
repeating the '--post-function' option with
|
||||
different arguments. See Post Function below.
|
||||
--exportdb EXPORTDB_FILE Specify alternate name for database file which
|
||||
--exportdb EXPORTDB_FILE Specify alternate path for database file which
|
||||
stores state information for export and
|
||||
--update. If --exportdb is not specified,
|
||||
export database will be saved to
|
||||
'.osxphotos_export.db' in the export
|
||||
directory. Must be specified as filename
|
||||
only, not a path, as export database will be
|
||||
saved in export directory.
|
||||
directory. If --exportdb is specified, it
|
||||
will be saved to the specified file.
|
||||
--load-config <config file path>
|
||||
Load options from file as written with --save-
|
||||
config. This allows you to save a complex
|
||||
@@ -1721,7 +1720,7 @@ Substitution Description
|
||||
{lf} A line feed: '\n', alias for {newline}
|
||||
{cr} A carriage return: '\r'
|
||||
{crlf} a carriage return + line feed: '\r\n'
|
||||
{osxphotos_version} The osxphotos version, e.g. '0.44.1'
|
||||
{osxphotos_version} The osxphotos version, e.g. '0.44.6'
|
||||
{osxphotos_cmd_line} The full command line used to run osxphotos
|
||||
|
||||
The following substitutions may result in multiple values. Thus if specified for
|
||||
@@ -3623,7 +3622,7 @@ The following template field substitutions are availabe for use the templating s
|
||||
|{lf}|A line feed: '\n', alias for {newline}|
|
||||
|{cr}|A carriage return: '\r'|
|
||||
|{crlf}|a carriage return + line feed: '\r\n'|
|
||||
|{osxphotos_version}|The osxphotos version, e.g. '0.44.1'|
|
||||
|{osxphotos_version}|The osxphotos version, e.g. '0.44.6'|
|
||||
|{osxphotos_cmd_line}|The full command line used to run osxphotos|
|
||||
|{album}|Album(s) photo is contained in|
|
||||
|{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Sphinx build info version 1
|
||||
# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
|
||||
config: 58505ca56d322ccc59c38bc440ccc347
|
||||
config: 12e2b2711a035185a2f8b8e500263a8d
|
||||
tags: 645f666f9bcd5a90fca523b33c5a78b7
|
||||
|
||||
2
docs/_static/documentation_options.js
vendored
2
docs/_static/documentation_options.js
vendored
@@ -1,6 +1,6 @@
|
||||
var DOCUMENTATION_OPTIONS = {
|
||||
URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
|
||||
VERSION: '0.44.1',
|
||||
VERSION: '0.44.6',
|
||||
LANGUAGE: 'None',
|
||||
COLLAPSE_INDEX: false,
|
||||
BUILDER: 'html',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
|
||||
|
||||
<title>osxphotos command line interface (CLI) — osxphotos 0.44.1 documentation</title>
|
||||
<title>osxphotos command line interface (CLI) — osxphotos 0.44.6 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Index — osxphotos 0.44.1 documentation</title>
|
||||
<title>Index — osxphotos 0.44.6 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
|
||||
|
||||
<title>Welcome to osxphotos’s documentation! — osxphotos 0.44.1 documentation</title>
|
||||
<title>Welcome to osxphotos’s documentation! — osxphotos 0.44.6 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
|
||||
|
||||
<title>osxphotos — osxphotos 0.44.1 documentation</title>
|
||||
<title>osxphotos — osxphotos 0.44.6 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
|
||||
|
||||
<title>osxphotos package — osxphotos 0.44.1 documentation</title>
|
||||
<title>osxphotos package — osxphotos 0.44.6 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Search — osxphotos 0.44.1 documentation</title>
|
||||
<title>Search — osxphotos 0.44.6 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from ._constants import AlbumSortOrder
|
||||
from ._version import __version__
|
||||
from .exiftool import ExifTool
|
||||
from .photoinfo import ExportResults, PhotoInfo
|
||||
from .photoexporter import ExportResults, PhotoExporter
|
||||
from .photoinfo import PhotoInfo
|
||||
from .photosdb import PhotosDB
|
||||
from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo
|
||||
from .phototemplate import PhotoTemplate
|
||||
|
||||
@@ -258,6 +258,7 @@ EXTENDED_ATTRIBUTE_NAMES_QUOTED = [f"'{x}'" for x in EXTENDED_ATTRIBUTE_NAMES]
|
||||
OSXPHOTOS_EXPORT_DB = ".osxphotos_export.db"
|
||||
|
||||
# bit flags for burst images ("burstPickType")
|
||||
BURST_PICK_TYPE_NONE = 0b0 # 0: sometimes used for single images with a burst UUID
|
||||
BURST_NOT_SELECTED = 0b10 # 2: burst image is not selected
|
||||
BURST_DEFAULT_PICK = 0b100 # 4: burst image is the one Photos picked to be key image before any selections made
|
||||
BURST_SELECTED = 0b1000 # 8: burst image is selected
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.44.2"
|
||||
__version__ = "0.44.6"
|
||||
|
||||
@@ -12,6 +12,7 @@ import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from runpy import run_module
|
||||
|
||||
import bitmath
|
||||
import click
|
||||
@@ -20,7 +21,6 @@ import photoscript
|
||||
import rich.traceback
|
||||
import yaml
|
||||
from rich import pretty
|
||||
from runpy import run_module
|
||||
|
||||
import osxphotos
|
||||
|
||||
@@ -56,7 +56,8 @@ from .exiftool import get_exiftool_path
|
||||
from .export_db import ExportDB, ExportDBInMemory
|
||||
from .fileutil import FileUtil, FileUtilNoOp
|
||||
from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
|
||||
from .photoinfo import ExportResults, PhotoInfo
|
||||
from .photoexporter import ExportResults, PhotoExporter
|
||||
from .photoinfo import PhotoInfo
|
||||
from .photokit import check_photokit_authorization, request_photokit_authorization
|
||||
from .photosalbum import PhotosAlbum
|
||||
from .phototemplate import PhotoTemplate, RenderOptions
|
||||
@@ -1059,10 +1060,9 @@ def cli(ctx, db, json_, debug):
|
||||
metavar="EXPORTDB_FILE",
|
||||
default=None,
|
||||
help=(
|
||||
"Specify alternate name for database file which stores state information for export and --update. "
|
||||
"Specify alternate path for database file which stores state information for export and --update. "
|
||||
f"If --exportdb is not specified, export database will be saved to '{OSXPHOTOS_EXPORT_DB}' "
|
||||
"in the export directory. Must be specified as filename only, not a path, as export database "
|
||||
"will be saved in export directory."
|
||||
"in the export directory. If --exportdb is specified, it will be saved to the specified file. "
|
||||
),
|
||||
type=click.Path(),
|
||||
)
|
||||
@@ -1572,25 +1572,23 @@ def export(
|
||||
|
||||
# sanity check exportdb
|
||||
if exportdb and exportdb != OSXPHOTOS_EXPORT_DB:
|
||||
if "/" in exportdb:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Error: --exportdb must be specified as filename not path; "
|
||||
+ f"export database will saved in export directory '{dest}'.",
|
||||
fg=CLI_COLOR_ERROR,
|
||||
)
|
||||
)
|
||||
raise click.Abort()
|
||||
elif pathlib.Path(pathlib.Path(dest) / OSXPHOTOS_EXPORT_DB).exists():
|
||||
if pathlib.Path(pathlib.Path(dest) / OSXPHOTOS_EXPORT_DB).exists():
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Warning: export database is '{exportdb}' but found '{OSXPHOTOS_EXPORT_DB}' in {dest}; using '{exportdb}'",
|
||||
fg=CLI_COLOR_WARNING,
|
||||
)
|
||||
)
|
||||
if pathlib.Path(exportdb).resolve().parent != pathlib.Path(dest):
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Warning: export database '{pathlib.Path(exportdb).resolve()}' is in a different directory than export destination '{dest}'",
|
||||
fg=CLI_COLOR_WARNING,
|
||||
)
|
||||
)
|
||||
|
||||
# open export database and assign copy/link/unlink functions
|
||||
export_db_path = os.path.join(dest, exportdb or OSXPHOTOS_EXPORT_DB)
|
||||
export_db_path = exportdb or os.path.join(dest, OSXPHOTOS_EXPORT_DB)
|
||||
|
||||
# check that export isn't in the parent or child of a previously exported library
|
||||
other_db_files = find_files_in_branch(dest, OSXPHOTOS_EXPORT_DB)
|
||||
@@ -1613,10 +1611,10 @@ def export(
|
||||
click.confirm("Do you want to continue?", abort=True)
|
||||
|
||||
if dry_run:
|
||||
export_db = ExportDBInMemory(export_db_path)
|
||||
export_db = ExportDBInMemory(dbfile=export_db_path, export_dir=dest)
|
||||
fileutil = FileUtilNoOp
|
||||
else:
|
||||
export_db = ExportDB(export_db_path)
|
||||
export_db = ExportDB(dbfile=export_db_path, export_dir=dest)
|
||||
fileutil = FileUtil
|
||||
|
||||
if verbose_:
|
||||
@@ -2954,12 +2952,11 @@ def export_photo_to_directory(
|
||||
tries += 1
|
||||
error = 0
|
||||
try:
|
||||
export_results = photo.export2(
|
||||
dest_path,
|
||||
original_filename=filename,
|
||||
exporter = PhotoExporter(photo)
|
||||
export_results = exporter.export2(
|
||||
dest=dest_path,
|
||||
edited=edited,
|
||||
original=export_original,
|
||||
edited_filename=filename,
|
||||
filename=filename,
|
||||
sidecar=sidecar_flags,
|
||||
sidecar_drop_ext=sidecar_drop_ext,
|
||||
live_photo=export_live,
|
||||
@@ -3466,7 +3463,7 @@ def write_finder_tags(
|
||||
skipped = []
|
||||
if keywords:
|
||||
# match whatever keywords would've been used in --exiftool or --sidecar
|
||||
exif = photo._exiftool_dict(
|
||||
exif = PhotoExporter(photo)._exiftool_dict(
|
||||
use_albums_as_keywords=album_keyword,
|
||||
use_persons_as_keywords=person_keyword,
|
||||
keyword_template=keyword_template,
|
||||
@@ -4205,8 +4202,12 @@ def repl(ctx, cli_obj, db, emacs):
|
||||
|
||||
from osxphotos import ExifTool, PhotoInfo, PhotosDB
|
||||
from osxphotos.albuminfo import AlbumInfo
|
||||
from osxphotos.momentinfo import MomentInfo
|
||||
from osxphotos.photoexporter import ExportResults, PhotoExporter
|
||||
from osxphotos.placeinfo import PlaceInfo
|
||||
from osxphotos.queryoptions import QueryOptions
|
||||
from osxphotos.scoreinfo import ScoreInfo
|
||||
from osxphotos.searchinfo import SearchInfo
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.disabled = True
|
||||
@@ -4239,7 +4240,9 @@ def repl(ctx, cli_obj, db, emacs):
|
||||
|
||||
print(f"Found {len(photos)} photos in {tictoc:0.2f} seconds\n")
|
||||
print("The following classes have been imported from osxphotos:")
|
||||
print("- AlbumInfo, ExifTool, PhotoInfo, PhotosDB, PlaceInfo, QueryOptions\n")
|
||||
print(
|
||||
"- AlbumInfo, ExifTool, PhotoInfo, PhotoExporter, ExportResults, PhotosDB, PlaceInfo, QueryOptions, MomentInfo, ScoreInfo, SearchInfo\n"
|
||||
)
|
||||
print("The following variables are defined:")
|
||||
print(f"- photosdb: PhotosDB() instance for {photosdb.library_path}")
|
||||
print(
|
||||
|
||||
28
osxphotos/exifinfo.py
Normal file
28
osxphotos/exifinfo.py
Normal file
@@ -0,0 +1,28 @@
|
||||
""" ExifInfo class to expose EXIF info from the library """
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExifInfo:
|
||||
"""EXIF info associated with a photo from the Photos library"""
|
||||
|
||||
flash_fired: bool
|
||||
iso: int
|
||||
metering_mode: int
|
||||
sample_rate: int
|
||||
track_format: int
|
||||
white_balance: int
|
||||
aperture: float
|
||||
bit_rate: float
|
||||
duration: float
|
||||
exposure_bias: float
|
||||
focal_length: float
|
||||
fps: float
|
||||
latitude: float
|
||||
longitude: float
|
||||
shutter_speed: float
|
||||
camera_make: str
|
||||
camera_model: str
|
||||
codec: str
|
||||
lens_model: str
|
||||
@@ -1,5 +1,6 @@
|
||||
""" Helper class for managing a database used by PhotoInfo.export for tracking state of exports and updates """
|
||||
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
@@ -14,7 +15,7 @@ from ._constants import OSXPHOTOS_EXPORT_DB
|
||||
from ._version import __version__
|
||||
|
||||
OSXPHOTOS_EXPORTDB_VERSION = "4.0"
|
||||
OSXPHOTOS_ABOUT_STRING = f"Created by osxphotos version {__version__} (https://github.com/RhetTbull/osxphotos) on {str(datetime.datetime.now())}"
|
||||
OSXPHOTOS_ABOUT_STRING = f"Created by osxphotos version {__version__} (https://github.com/RhetTbull/osxphotos) on {datetime.datetime.now()}"
|
||||
|
||||
|
||||
class ExportDB_ABC(ABC):
|
||||
@@ -171,7 +172,7 @@ class ExportDBNoOp(ExportDB_ABC):
|
||||
return []
|
||||
|
||||
def get_detected_text_for_uuid(self, uuid):
|
||||
return None
|
||||
return None
|
||||
|
||||
def set_detected_text_for_uuid(self, uuid, json_text):
|
||||
pass
|
||||
@@ -193,15 +194,14 @@ class ExportDBNoOp(ExportDB_ABC):
|
||||
class ExportDB(ExportDB_ABC):
|
||||
"""Interface to sqlite3 database used to store state information for osxphotos export command"""
|
||||
|
||||
def __init__(self, dbfile):
|
||||
def __init__(self, dbfile, export_dir):
|
||||
"""dbfile: path to osxphotos export database file"""
|
||||
self._dbfile = dbfile
|
||||
# _path is parent of the database
|
||||
# all files referenced by get_/set_uuid_for_file will be converted to
|
||||
# relative paths to this parent _path
|
||||
# export_dir is required as all files referenced by get_/set_uuid_for_file will be converted to
|
||||
# relative paths to this path
|
||||
# this allows the entire export tree to be moved to a new disk/location
|
||||
# whilst preserving the UUID to filename mapping
|
||||
self._path = pathlib.Path(dbfile).parent
|
||||
self._path = export_dir
|
||||
self._conn = self._open_export_db(dbfile)
|
||||
self._insert_run_info()
|
||||
|
||||
@@ -214,14 +214,13 @@ class ExportDB(ExportDB_ABC):
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
f"SELECT uuid FROM files WHERE filepath_normalized = ?", (filename,)
|
||||
"SELECT uuid FROM files WHERE filepath_normalized = ?", (filename,)
|
||||
)
|
||||
results = c.fetchone()
|
||||
uuid = results[0] if results else None
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
uuid = None
|
||||
|
||||
return uuid
|
||||
|
||||
def set_uuid_for_file(self, filename, uuid):
|
||||
@@ -232,9 +231,10 @@ class ExportDB(ExportDB_ABC):
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
f"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
|
||||
"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
|
||||
(filename, filename_normalized, uuid),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
@@ -274,15 +274,14 @@ class ExportDB(ExportDB_ABC):
|
||||
)
|
||||
results = c.fetchone()
|
||||
if results:
|
||||
stats = results[0:3]
|
||||
stats = results[:3]
|
||||
mtime = int(stats[2]) if stats[2] is not None else None
|
||||
stats = (stats[0], stats[1], mtime)
|
||||
else:
|
||||
stats = (None, None, None)
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
stats = (None, None, None)
|
||||
|
||||
stats = None, None, None
|
||||
return stats
|
||||
|
||||
def set_stat_edited_for_file(self, filename, stats):
|
||||
@@ -332,15 +331,14 @@ class ExportDB(ExportDB_ABC):
|
||||
)
|
||||
results = c.fetchone()
|
||||
if results:
|
||||
stats = results[0:3]
|
||||
stats = results[:3]
|
||||
mtime = int(stats[2]) if stats[2] is not None else None
|
||||
stats = (stats[0], stats[1], mtime)
|
||||
else:
|
||||
stats = (None, None, None)
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
stats = (None, None, None)
|
||||
|
||||
stats = None, None, None
|
||||
return stats
|
||||
|
||||
def set_stat_converted_for_file(self, filename, stats):
|
||||
@@ -493,7 +491,10 @@ class ExportDB(ExportDB_ABC):
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO detected_text(uuid, text_data) VALUES (?, ?);",
|
||||
(uuid, text_json,),
|
||||
(
|
||||
uuid,
|
||||
text_json,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
@@ -517,9 +518,10 @@ class ExportDB(ExportDB_ABC):
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
f"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
|
||||
"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
|
||||
(filename, filename_normalized, uuid),
|
||||
)
|
||||
|
||||
c.execute(
|
||||
"UPDATE files "
|
||||
+ "SET orig_mode = ?, orig_size = ?, orig_mtime = ? "
|
||||
@@ -582,7 +584,7 @@ class ExportDB(ExportDB_ABC):
|
||||
)
|
||||
results = c.fetchone()
|
||||
if results:
|
||||
stats = results[0:3]
|
||||
stats = results[:3]
|
||||
mtime = int(stats[2]) if stats[2] is not None else None
|
||||
stats = (stats[0], stats[1], mtime)
|
||||
else:
|
||||
@@ -741,9 +743,10 @@ class ExportDB(ExportDB_ABC):
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
f"INSERT INTO runs (datetime, python_path, script_name, args, cwd) VALUES (?, ?, ?, ?, ?)",
|
||||
"INSERT INTO runs (datetime, python_path, script_name, args, cwd) VALUES (?, ?, ?, ?, ?)",
|
||||
(dt, python_path, cmd, args, cwd),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
@@ -755,14 +758,13 @@ class ExportDBInMemory(ExportDB):
|
||||
modifying the on-disk version
|
||||
"""
|
||||
|
||||
def __init__(self, dbfile):
|
||||
def __init__(self, dbfile, export_dir):
|
||||
self._dbfile = dbfile or f"./{OSXPHOTOS_EXPORT_DB}"
|
||||
# _path is parent of the database
|
||||
# all files referenced by get_/set_uuid_for_file will be converted to
|
||||
# relative paths to this parent _path
|
||||
# export_dir is required as all files referenced by get_/set_uuid_for_file will be converted to
|
||||
# relative paths to this path
|
||||
# this allows the entire export tree to be moved to a new disk/location
|
||||
# whilst preserving the UUID to filename mapping
|
||||
self._path = pathlib.Path(self._dbfile).parent
|
||||
self._path = export_dir
|
||||
self._conn = self._open_export_db(self._dbfile)
|
||||
self._insert_run_info()
|
||||
|
||||
|
||||
@@ -17,25 +17,25 @@ from wurlitzer import pipes
|
||||
|
||||
|
||||
class ImageConversionError(Exception):
|
||||
"""Base class for exceptions in this module. """
|
||||
"""Base class for exceptions in this module."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ImageConverter:
|
||||
""" Convert images to jpeg. This class is a singleton
|
||||
which will re-use the Core Image CIContext to avoid
|
||||
creating a new context for every conversion. """
|
||||
"""Convert images to jpeg. This class is a singleton
|
||||
which will re-use the Core Image CIContext to avoid
|
||||
creating a new context for every conversion."""
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
""" create new object or return instance of already created singleton """
|
||||
"""create new object or return instance of already created singleton"""
|
||||
if not hasattr(cls, "instance") or not cls.instance:
|
||||
cls.instance = super().__new__(cls)
|
||||
|
||||
return cls.instance
|
||||
|
||||
def __init__(self):
|
||||
""" return existing singleton or create a new one """
|
||||
"""return existing singleton or create a new one"""
|
||||
|
||||
if hasattr(self, "context"):
|
||||
return
|
||||
@@ -47,13 +47,10 @@ class ImageConverter:
|
||||
"workingFormat": Quartz.kCIFormatRGBAh,
|
||||
}
|
||||
)
|
||||
mtldevice = Metal.MTLCreateSystemDefaultDevice()
|
||||
self.context = Quartz.CIContext.contextWithMTLDevice_options_(
|
||||
mtldevice, context_options
|
||||
)
|
||||
self.context = Quartz.CIContext.contextWithOptions_(context_options)
|
||||
|
||||
def write_jpeg(self, input_path, output_path, compression_quality=1.0):
|
||||
""" convert image to jpeg and write image to output_path
|
||||
"""convert image to jpeg and write image to output_path
|
||||
|
||||
Args:
|
||||
input_path: path to input image (e.g. '/path/to/import/file.CR2') as str or pathlib.Path
|
||||
@@ -104,8 +101,11 @@ class ImageConverter:
|
||||
if input_image is None:
|
||||
raise ImageConversionError(f"Could not create CIImage for {input_path}")
|
||||
|
||||
output_colorspace = input_image.colorSpace() or Quartz.CGColorSpaceCreateWithName(
|
||||
Quartz.CoreGraphics.kCGColorSpaceSRGB
|
||||
output_colorspace = (
|
||||
input_image.colorSpace()
|
||||
or Quartz.CGColorSpaceCreateWithName(
|
||||
Quartz.CoreGraphics.kCGColorSpaceSRGB
|
||||
)
|
||||
)
|
||||
|
||||
output_options = NSDictionary.dictionaryWithDictionary_(
|
||||
@@ -123,4 +123,3 @@ class ImageConverter:
|
||||
raise ImageConversionError(
|
||||
f"Error converting file {input_path} to jpeg at {output_path}: {error}"
|
||||
)
|
||||
|
||||
|
||||
2135
osxphotos/photoexporter.py
Normal file
2135
osxphotos/photoexporter.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ from typing import Optional
|
||||
import yaml
|
||||
from osxmetadata import OSXMetaData
|
||||
|
||||
from .._constants import (
|
||||
from ._constants import (
|
||||
_MOVIE_TYPE,
|
||||
_PHOTO_TYPE,
|
||||
_PHOTOS_4_ALBUM_KIND,
|
||||
@@ -37,16 +37,21 @@ from .._constants import (
|
||||
BURST_SELECTED,
|
||||
TEXT_DETECTION_CONFIDENCE_THRESHOLD,
|
||||
)
|
||||
from ..adjustmentsinfo import AdjustmentsInfo
|
||||
from ..albuminfo import AlbumInfo, ImportInfo, ProjectInfo
|
||||
from ..momentinfo import MomentInfo
|
||||
from ..personinfo import FaceInfo, PersonInfo
|
||||
from ..phototemplate import PhotoTemplate, RenderOptions
|
||||
from ..placeinfo import PlaceInfo4, PlaceInfo5
|
||||
from ..query_builder import get_query
|
||||
from ..text_detection import detect_text
|
||||
from ..uti import get_preferred_uti_extension, get_uti_for_extension
|
||||
from ..utils import _debug, _get_resource_loc, findfiles
|
||||
from .adjustmentsinfo import AdjustmentsInfo
|
||||
from .albuminfo import AlbumInfo, ImportInfo, ProjectInfo
|
||||
from .exifinfo import ExifInfo
|
||||
from .exiftool import ExifToolCaching, get_exiftool_path
|
||||
from .momentinfo import MomentInfo
|
||||
from .personinfo import FaceInfo, PersonInfo
|
||||
from .photoexporter import PhotoExporter
|
||||
from .phototemplate import PhotoTemplate, RenderOptions
|
||||
from .placeinfo import PlaceInfo4, PlaceInfo5
|
||||
from .query_builder import get_query
|
||||
from .scoreinfo import ScoreInfo
|
||||
from .searchinfo import SearchInfo
|
||||
from .text_detection import detect_text
|
||||
from .uti import get_preferred_uti_extension, get_uti_for_extension
|
||||
from .utils import _debug, _get_resource_loc, findfiles
|
||||
|
||||
|
||||
class PhotoInfo:
|
||||
@@ -55,42 +60,12 @@ class PhotoInfo:
|
||||
including keywords, persons, albums, uuid, path, etc.
|
||||
"""
|
||||
|
||||
# import additional methods
|
||||
from ._photoinfo_comments import comments, likes
|
||||
from ._photoinfo_exifinfo import ExifInfo, exif_info
|
||||
from ._photoinfo_exiftool import exiftool
|
||||
from ._photoinfo_export import (
|
||||
ExportResults,
|
||||
_exiftool_dict,
|
||||
_exiftool_json_sidecar,
|
||||
_export_photo,
|
||||
_export_photo_with_photos_export,
|
||||
_get_exif_keywords,
|
||||
_get_exif_persons,
|
||||
_write_exif_data,
|
||||
_write_sidecar,
|
||||
_xmp_sidecar,
|
||||
export,
|
||||
export2,
|
||||
)
|
||||
from ._photoinfo_scoreinfo import ScoreInfo, score
|
||||
from ._photoinfo_searchinfo import (
|
||||
SearchInfo,
|
||||
labels,
|
||||
labels_normalized,
|
||||
search_info,
|
||||
search_info_normalized,
|
||||
)
|
||||
|
||||
def __init__(self, db=None, uuid=None, info=None):
|
||||
self._uuid = uuid
|
||||
self._info = info
|
||||
self._db = db
|
||||
self._verbose = self._db._verbose
|
||||
|
||||
# TODO: remove this once refactor of PhotoExporter is done
|
||||
self._render_options = RenderOptions()
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
"""filename of the picture"""
|
||||
@@ -750,8 +725,10 @@ class PhotoInfo:
|
||||
self._uti_original = self.uti
|
||||
elif self._db._photos_ver >= 7:
|
||||
# Monterey+
|
||||
self._uti_original = get_uti_for_extension(
|
||||
pathlib.Path(self.original_filename).suffix
|
||||
# there are some cases with UTI_original is None (photo imported with no extension) so fallback to UTI and hope it's right
|
||||
self._uti_original = (
|
||||
get_uti_for_extension(pathlib.Path(self.original_filename).suffix)
|
||||
or self.uti
|
||||
)
|
||||
else:
|
||||
self._uti_original = self._info["UTI_original"]
|
||||
@@ -1050,7 +1027,7 @@ class PhotoInfo:
|
||||
@property
|
||||
def israw(self):
|
||||
"""returns True if photo is a raw image. For images with an associated RAW+JPEG pair, see has_raw"""
|
||||
return "raw-image" in self.uti_original
|
||||
return "raw-image" in self.uti_original if self.uti_original else False
|
||||
|
||||
@property
|
||||
def raw_original(self):
|
||||
@@ -1140,21 +1117,239 @@ class PhotoInfo:
|
||||
self._owner = None
|
||||
return self._owner
|
||||
|
||||
def render_template(
|
||||
self, template_str: str, options: Optional[RenderOptions] = None
|
||||
):
|
||||
"""Renders a template string for PhotoInfo instance using PhotoTemplate
|
||||
|
||||
Args:
|
||||
template_str: a template string with fields to render
|
||||
options: a RenderOptions instance
|
||||
@property
|
||||
def score(self):
|
||||
"""Computed score information for a photo
|
||||
|
||||
Returns:
|
||||
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
|
||||
ScoreInfo instance
|
||||
"""
|
||||
options = options or RenderOptions()
|
||||
template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path)
|
||||
return template.render(template_str, options)
|
||||
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
logging.debug(f"score not implemented for this database version")
|
||||
return None
|
||||
|
||||
try:
|
||||
return self._scoreinfo # pylint: disable=access-member-before-definition
|
||||
except AttributeError:
|
||||
try:
|
||||
scores = self._db._db_scoreinfo_uuid[self.uuid]
|
||||
self._scoreinfo = ScoreInfo(
|
||||
overall=scores["overall_aesthetic"],
|
||||
curation=scores["curation"],
|
||||
promotion=scores["promotion"],
|
||||
highlight_visibility=scores["highlight_visibility"],
|
||||
behavioral=scores["behavioral"],
|
||||
failure=scores["failure"],
|
||||
harmonious_color=scores["harmonious_color"],
|
||||
immersiveness=scores["immersiveness"],
|
||||
interaction=scores["interaction"],
|
||||
interesting_subject=scores["interesting_subject"],
|
||||
intrusive_object_presence=scores["intrusive_object_presence"],
|
||||
lively_color=scores["lively_color"],
|
||||
low_light=scores["low_light"],
|
||||
noise=scores["noise"],
|
||||
pleasant_camera_tilt=scores["pleasant_camera_tilt"],
|
||||
pleasant_composition=scores["pleasant_composition"],
|
||||
pleasant_lighting=scores["pleasant_lighting"],
|
||||
pleasant_pattern=scores["pleasant_pattern"],
|
||||
pleasant_perspective=scores["pleasant_perspective"],
|
||||
pleasant_post_processing=scores["pleasant_post_processing"],
|
||||
pleasant_reflection=scores["pleasant_reflection"],
|
||||
pleasant_symmetry=scores["pleasant_symmetry"],
|
||||
sharply_focused_subject=scores["sharply_focused_subject"],
|
||||
tastefully_blurred=scores["tastefully_blurred"],
|
||||
well_chosen_subject=scores["well_chosen_subject"],
|
||||
well_framed_subject=scores["well_framed_subject"],
|
||||
well_timed_shot=scores["well_timed_shot"],
|
||||
)
|
||||
return self._scoreinfo
|
||||
except KeyError:
|
||||
self._scoreinfo = ScoreInfo(
|
||||
overall=0.0,
|
||||
curation=0.0,
|
||||
promotion=0.0,
|
||||
highlight_visibility=0.0,
|
||||
behavioral=0.0,
|
||||
failure=0.0,
|
||||
harmonious_color=0.0,
|
||||
immersiveness=0.0,
|
||||
interaction=0.0,
|
||||
interesting_subject=0.0,
|
||||
intrusive_object_presence=0.0,
|
||||
lively_color=0.0,
|
||||
low_light=0.0,
|
||||
noise=0.0,
|
||||
pleasant_camera_tilt=0.0,
|
||||
pleasant_composition=0.0,
|
||||
pleasant_lighting=0.0,
|
||||
pleasant_pattern=0.0,
|
||||
pleasant_perspective=0.0,
|
||||
pleasant_post_processing=0.0,
|
||||
pleasant_reflection=0.0,
|
||||
pleasant_symmetry=0.0,
|
||||
sharply_focused_subject=0.0,
|
||||
tastefully_blurred=0.0,
|
||||
well_chosen_subject=0.0,
|
||||
well_framed_subject=0.0,
|
||||
well_timed_shot=0.0,
|
||||
)
|
||||
return self._scoreinfo
|
||||
|
||||
@property
|
||||
def search_info(self):
|
||||
"""returns SearchInfo object for photo
|
||||
only valid on Photos 5, on older libraries, returns None
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return None
|
||||
|
||||
# memoize SearchInfo object
|
||||
try:
|
||||
return self._search_info
|
||||
except AttributeError:
|
||||
self._search_info = SearchInfo(self)
|
||||
return self._search_info
|
||||
|
||||
@property
|
||||
def search_info_normalized(self):
|
||||
"""returns SearchInfo object for photo that produces normalized results
|
||||
only valid on Photos 5, on older libraries, returns None
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return None
|
||||
|
||||
# memoize SearchInfo object
|
||||
try:
|
||||
return self._search_info_normalized
|
||||
except AttributeError:
|
||||
self._search_info_normalized = SearchInfo(self, normalized=True)
|
||||
return self._search_info_normalized
|
||||
|
||||
@property
|
||||
def labels(self):
|
||||
"""returns list of labels applied to photo by Photos image categorization
|
||||
only valid on Photos 5, on older libraries returns empty list
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return []
|
||||
|
||||
return self.search_info.labels
|
||||
|
||||
@property
|
||||
def labels_normalized(self):
|
||||
"""returns normalized list of labels applied to photo by Photos image categorization
|
||||
only valid on Photos 5, on older libraries returns empty list
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return []
|
||||
|
||||
return self.search_info_normalized.labels
|
||||
|
||||
@property
|
||||
def comments(self):
|
||||
"""Returns list of Comment objects for any comments on the photo (sorted by date)"""
|
||||
try:
|
||||
return self._db._db_comments_uuid[self.uuid]["comments"]
|
||||
except:
|
||||
return []
|
||||
|
||||
@property
|
||||
def likes(self):
|
||||
"""Returns list of Like objects for any likes on the photo (sorted by date)"""
|
||||
try:
|
||||
return self._db._db_comments_uuid[self.uuid]["likes"]
|
||||
except:
|
||||
return []
|
||||
|
||||
@property
|
||||
def exif_info(self):
|
||||
"""Returns an ExifInfo object with the EXIF data for photo
|
||||
Note: the returned EXIF data is the data Photos stores in the database on import;
|
||||
ExifInfo does not provide access to the EXIF info in the actual image file
|
||||
Some or all of the fields may be None
|
||||
Only valid for Photos 5; on earlier database returns None
|
||||
"""
|
||||
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
logging.debug(f"exif_info not implemented for this database version")
|
||||
return None
|
||||
|
||||
try:
|
||||
exif = self._db._db_exifinfo_uuid[self.uuid]
|
||||
exif_info = ExifInfo(
|
||||
iso=exif["ZISO"],
|
||||
flash_fired=True if exif["ZFLASHFIRED"] == 1 else False,
|
||||
metering_mode=exif["ZMETERINGMODE"],
|
||||
sample_rate=exif["ZSAMPLERATE"],
|
||||
track_format=exif["ZTRACKFORMAT"],
|
||||
white_balance=exif["ZWHITEBALANCE"],
|
||||
aperture=exif["ZAPERTURE"],
|
||||
bit_rate=exif["ZBITRATE"],
|
||||
duration=exif["ZDURATION"],
|
||||
exposure_bias=exif["ZEXPOSUREBIAS"],
|
||||
focal_length=exif["ZFOCALLENGTH"],
|
||||
fps=exif["ZFPS"],
|
||||
latitude=exif["ZLATITUDE"],
|
||||
longitude=exif["ZLONGITUDE"],
|
||||
shutter_speed=exif["ZSHUTTERSPEED"],
|
||||
camera_make=exif["ZCAMERAMAKE"],
|
||||
camera_model=exif["ZCAMERAMODEL"],
|
||||
codec=exif["ZCODEC"],
|
||||
lens_model=exif["ZLENSMODEL"],
|
||||
)
|
||||
except KeyError:
|
||||
logging.debug(f"Could not find exif record for uuid {self.uuid}")
|
||||
exif_info = ExifInfo(
|
||||
iso=None,
|
||||
flash_fired=None,
|
||||
metering_mode=None,
|
||||
sample_rate=None,
|
||||
track_format=None,
|
||||
white_balance=None,
|
||||
aperture=None,
|
||||
bit_rate=None,
|
||||
duration=None,
|
||||
exposure_bias=None,
|
||||
focal_length=None,
|
||||
fps=None,
|
||||
latitude=None,
|
||||
longitude=None,
|
||||
shutter_speed=None,
|
||||
camera_make=None,
|
||||
camera_model=None,
|
||||
codec=None,
|
||||
lens_model=None,
|
||||
)
|
||||
|
||||
return exif_info
|
||||
|
||||
@property
|
||||
def exiftool(self):
|
||||
"""Returns a ExifToolCaching (read-only instance of ExifTool) object for the photo.
|
||||
Requires that exiftool (https://exiftool.org/) be installed
|
||||
If exiftool not installed, logs warning and returns None
|
||||
If photo path is missing, returns None
|
||||
"""
|
||||
try:
|
||||
# return the memoized instance if it exists
|
||||
return self._exiftool
|
||||
except AttributeError:
|
||||
try:
|
||||
exiftool_path = self._db._exiftool_path or get_exiftool_path()
|
||||
if self.path is not None and os.path.isfile(self.path):
|
||||
exiftool = ExifToolCaching(self.path, exiftool=exiftool_path)
|
||||
else:
|
||||
exiftool = None
|
||||
except FileNotFoundError:
|
||||
# get_exiftool_path raises FileNotFoundError if exiftool not found
|
||||
exiftool = None
|
||||
logging.warning(
|
||||
"exiftool not in path; download and install from https://exiftool.org/"
|
||||
)
|
||||
|
||||
self._exiftool = exiftool
|
||||
return self._exiftool
|
||||
|
||||
def detected_text(self, confidence_threshold=TEXT_DETECTION_CONFIDENCE_THRESHOLD):
|
||||
"""Detects text in photo and returns lists of results as (detected text, confidence)
|
||||
@@ -1213,6 +1408,108 @@ class PhotoInfo:
|
||||
"""Returns latitude, in degrees"""
|
||||
return self._info["latitude"]
|
||||
|
||||
def render_template(
|
||||
self, template_str: str, options: Optional[RenderOptions] = None
|
||||
):
|
||||
"""Renders a template string for PhotoInfo instance using PhotoTemplate
|
||||
|
||||
Args:
|
||||
template_str: a template string with fields to render
|
||||
options: a RenderOptions instance
|
||||
|
||||
Returns:
|
||||
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
|
||||
"""
|
||||
options = options or RenderOptions()
|
||||
template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path)
|
||||
return template.render(template_str, options)
|
||||
|
||||
def export(
|
||||
self,
|
||||
dest,
|
||||
filename=None,
|
||||
edited=False,
|
||||
live_photo=False,
|
||||
raw_photo=False,
|
||||
export_as_hardlink=False,
|
||||
overwrite=False,
|
||||
increment=True,
|
||||
sidecar_json=False,
|
||||
sidecar_exiftool=False,
|
||||
sidecar_xmp=False,
|
||||
use_photos_export=False,
|
||||
timeout=120,
|
||||
exiftool=False,
|
||||
use_albums_as_keywords=False,
|
||||
use_persons_as_keywords=False,
|
||||
keyword_template=None,
|
||||
description_template=None,
|
||||
render_options: Optional[RenderOptions] = None,
|
||||
):
|
||||
"""export photo
|
||||
dest: must be valid destination path (or exception raised)
|
||||
filename: (optional): name of exported picture; if not provided, will use current filename
|
||||
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
|
||||
For example, if photo is .CR2 file, edited image may be .jpeg.
|
||||
If you provide an extension different than what the actual file is,
|
||||
export will print a warning but will export the photo using the
|
||||
incorrect file extension (unless use_photos_export is true, in which case export will
|
||||
use the extension provided by Photos upon export; in this case, an incorrect extension is
|
||||
silently ignored).
|
||||
e.g. to get the extension of the edited photo,
|
||||
reference PhotoInfo.path_edited
|
||||
edited: (boolean, default=False); if True will export the edited version of the photo, otherwise exports the original version
|
||||
(or raise exception if no edited version)
|
||||
live_photo: (boolean, default=False); if True, will also export the associated .mov for live photos
|
||||
raw_photo: (boolean, default=False); if True, will also export the associated RAW photo
|
||||
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
|
||||
overwrite: (boolean, default=False); if True will overwrite files if they already exist
|
||||
increment: (boolean, default=True); if 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
|
||||
sidecar_json: if set will write a json sidecar with data in format readable by exiftool
|
||||
sidecar filename will be dest/filename.json; includes exiftool tag group names (e.g. `exiftool -G -j`)
|
||||
sidecar_exiftool: if set will write a json sidecar with data in format readable by exiftool
|
||||
sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`)
|
||||
sidecar_xmp: if set will write an XMP sidecar with IPTC data
|
||||
sidecar filename will be dest/filename.xmp
|
||||
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
|
||||
timeout: (int, default=120) timeout in seconds used with use_photos_export
|
||||
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
|
||||
returns list of full paths to the exported files
|
||||
use_albums_as_keywords: (boolean, default = False); if True, will include album names in keywords
|
||||
when exporting metadata with exiftool or sidecar
|
||||
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
|
||||
when exporting metadata with exiftool or sidecar
|
||||
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
|
||||
description_template: string; optional template string that will be rendered for use as photo description
|
||||
render_options: an optional osxphotos.phototemplate.RenderOptions instance with options to pass to template renderer
|
||||
|
||||
Returns: list of photos exported
|
||||
"""
|
||||
|
||||
exporter = PhotoExporter(self)
|
||||
return exporter.export(
|
||||
dest=dest,
|
||||
filename=filename,
|
||||
edited=edited,
|
||||
live_photo=live_photo,
|
||||
raw_photo=raw_photo,
|
||||
export_as_hardlink=export_as_hardlink,
|
||||
overwrite=overwrite,
|
||||
increment=increment,
|
||||
sidecar_json=sidecar_json,
|
||||
sidecar_exiftool=sidecar_exiftool,
|
||||
sidecar_xmp=sidecar_xmp,
|
||||
use_photos_export=use_photos_export,
|
||||
timeout=timeout,
|
||||
exiftool=exiftool,
|
||||
use_albums_as_keywords=use_albums_as_keywords,
|
||||
use_persons_as_keywords=use_persons_as_keywords,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
render_options=render_options,
|
||||
)
|
||||
|
||||
def _get_album_uuids(self, project=False):
|
||||
"""Return list of album UUIDs this photo is found in
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"""
|
||||
PhotoInfo class
|
||||
Represents a single photo in the Photos library and provides access to the photo's attributes
|
||||
PhotosDB.photos() returns a list of PhotoInfo objects
|
||||
"""
|
||||
|
||||
from ._photoinfo_exifinfo import ExifInfo
|
||||
from ._photoinfo_export import ExportResults
|
||||
from ._photoinfo_scoreinfo import ScoreInfo
|
||||
from .photoinfo import PhotoInfo, PhotoInfoNone
|
||||
@@ -1,17 +0,0 @@
|
||||
""" PhotoInfo methods to expose comments and likes for shared photos """
|
||||
|
||||
@property
|
||||
def comments(self):
|
||||
""" Returns list of Comment objects for any comments on the photo (sorted by date) """
|
||||
try:
|
||||
return self._db._db_comments_uuid[self.uuid]["comments"]
|
||||
except:
|
||||
return []
|
||||
|
||||
@property
|
||||
def likes(self):
|
||||
""" Returns list of Like objects for any likes on the photo (sorted by date) """
|
||||
try:
|
||||
return self._db._db_comments_uuid[self.uuid]["likes"]
|
||||
except:
|
||||
return []
|
||||
@@ -1,94 +0,0 @@
|
||||
""" PhotoInfo methods to expose EXIF info from the library """
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .._constants import _PHOTOS_4_VERSION
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExifInfo:
|
||||
""" EXIF info associated with a photo from the Photos library """
|
||||
|
||||
flash_fired: bool
|
||||
iso: int
|
||||
metering_mode: int
|
||||
sample_rate: int
|
||||
track_format: int
|
||||
white_balance: int
|
||||
aperture: float
|
||||
bit_rate: float
|
||||
duration: float
|
||||
exposure_bias: float
|
||||
focal_length: float
|
||||
fps: float
|
||||
latitude: float
|
||||
longitude: float
|
||||
shutter_speed: float
|
||||
camera_make: str
|
||||
camera_model: str
|
||||
codec: str
|
||||
lens_model: str
|
||||
|
||||
|
||||
@property
|
||||
def exif_info(self):
|
||||
""" Returns an ExifInfo object with the EXIF data for photo
|
||||
Note: the returned EXIF data is the data Photos stores in the database on import;
|
||||
ExifInfo does not provide access to the EXIF info in the actual image file
|
||||
Some or all of the fields may be None
|
||||
Only valid for Photos 5; on earlier database returns None
|
||||
"""
|
||||
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
logging.debug(f"exif_info not implemented for this database version")
|
||||
return None
|
||||
|
||||
try:
|
||||
exif = self._db._db_exifinfo_uuid[self.uuid]
|
||||
exif_info = ExifInfo(
|
||||
iso=exif["ZISO"],
|
||||
flash_fired=True if exif["ZFLASHFIRED"] == 1 else False,
|
||||
metering_mode=exif["ZMETERINGMODE"],
|
||||
sample_rate=exif["ZSAMPLERATE"],
|
||||
track_format=exif["ZTRACKFORMAT"],
|
||||
white_balance=exif["ZWHITEBALANCE"],
|
||||
aperture=exif["ZAPERTURE"],
|
||||
bit_rate=exif["ZBITRATE"],
|
||||
duration=exif["ZDURATION"],
|
||||
exposure_bias=exif["ZEXPOSUREBIAS"],
|
||||
focal_length=exif["ZFOCALLENGTH"],
|
||||
fps=exif["ZFPS"],
|
||||
latitude=exif["ZLATITUDE"],
|
||||
longitude=exif["ZLONGITUDE"],
|
||||
shutter_speed=exif["ZSHUTTERSPEED"],
|
||||
camera_make=exif["ZCAMERAMAKE"],
|
||||
camera_model=exif["ZCAMERAMODEL"],
|
||||
codec=exif["ZCODEC"],
|
||||
lens_model=exif["ZLENSMODEL"],
|
||||
)
|
||||
except KeyError:
|
||||
logging.debug(f"Could not find exif record for uuid {self.uuid}")
|
||||
exif_info = ExifInfo(
|
||||
iso=None,
|
||||
flash_fired=None,
|
||||
metering_mode=None,
|
||||
sample_rate=None,
|
||||
track_format=None,
|
||||
white_balance=None,
|
||||
aperture=None,
|
||||
bit_rate=None,
|
||||
duration=None,
|
||||
exposure_bias=None,
|
||||
focal_length=None,
|
||||
fps=None,
|
||||
latitude=None,
|
||||
longitude=None,
|
||||
shutter_speed=None,
|
||||
camera_make=None,
|
||||
camera_model=None,
|
||||
codec=None,
|
||||
lens_model=None,
|
||||
)
|
||||
|
||||
return exif_info
|
||||
@@ -1,34 +0,0 @@
|
||||
""" Implementation for PhotoInfo.exiftool property which returns ExifTool object for a photo """
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from ..exiftool import ExifToolCaching, get_exiftool_path
|
||||
|
||||
|
||||
@property
|
||||
def exiftool(self):
|
||||
""" Returns a ExifToolCaching (read-only instance of ExifTool) object for the photo.
|
||||
Requires that exiftool (https://exiftool.org/) be installed
|
||||
If exiftool not installed, logs warning and returns None
|
||||
If photo path is missing, returns None
|
||||
"""
|
||||
try:
|
||||
# return the memoized instance if it exists
|
||||
return self._exiftool
|
||||
except AttributeError:
|
||||
try:
|
||||
exiftool_path = self._db._exiftool_path or get_exiftool_path()
|
||||
if self.path is not None and os.path.isfile(self.path):
|
||||
exiftool = ExifToolCaching(self.path, exiftool=exiftool_path)
|
||||
else:
|
||||
exiftool = None
|
||||
except FileNotFoundError:
|
||||
# get_exiftool_path raises FileNotFoundError if exiftool not found
|
||||
exiftool = None
|
||||
logging.warning(
|
||||
f"exiftool not in path; download and install from https://exiftool.org/"
|
||||
)
|
||||
|
||||
self._exiftool = exiftool
|
||||
return self._exiftool
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,119 +0,0 @@
|
||||
""" PhotoInfo methods to expose computed score info from the library """
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .._constants import _PHOTOS_4_VERSION
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScoreInfo:
|
||||
""" Computed photo score info associated with a photo from the Photos library """
|
||||
|
||||
overall: float
|
||||
curation: float
|
||||
promotion: float
|
||||
highlight_visibility: float
|
||||
behavioral: float
|
||||
failure: float
|
||||
harmonious_color: float
|
||||
immersiveness: float
|
||||
interaction: float
|
||||
interesting_subject: float
|
||||
intrusive_object_presence: float
|
||||
lively_color: float
|
||||
low_light: float
|
||||
noise: float
|
||||
pleasant_camera_tilt: float
|
||||
pleasant_composition: float
|
||||
pleasant_lighting: float
|
||||
pleasant_pattern: float
|
||||
pleasant_perspective: float
|
||||
pleasant_post_processing: float
|
||||
pleasant_reflection: float
|
||||
pleasant_symmetry: float
|
||||
sharply_focused_subject: float
|
||||
tastefully_blurred: float
|
||||
well_chosen_subject: float
|
||||
well_framed_subject: float
|
||||
well_timed_shot: float
|
||||
|
||||
|
||||
@property
|
||||
def score(self):
|
||||
""" Computed score information for a photo
|
||||
|
||||
Returns:
|
||||
ScoreInfo instance
|
||||
"""
|
||||
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
logging.debug(f"score not implemented for this database version")
|
||||
return None
|
||||
|
||||
try:
|
||||
return self._scoreinfo # pylint: disable=access-member-before-definition
|
||||
except AttributeError:
|
||||
try:
|
||||
scores = self._db._db_scoreinfo_uuid[self.uuid]
|
||||
self._scoreinfo = ScoreInfo(
|
||||
overall=scores["overall_aesthetic"],
|
||||
curation=scores["curation"],
|
||||
promotion=scores["promotion"],
|
||||
highlight_visibility=scores["highlight_visibility"],
|
||||
behavioral=scores["behavioral"],
|
||||
failure=scores["failure"],
|
||||
harmonious_color=scores["harmonious_color"],
|
||||
immersiveness=scores["immersiveness"],
|
||||
interaction=scores["interaction"],
|
||||
interesting_subject=scores["interesting_subject"],
|
||||
intrusive_object_presence=scores["intrusive_object_presence"],
|
||||
lively_color=scores["lively_color"],
|
||||
low_light=scores["low_light"],
|
||||
noise=scores["noise"],
|
||||
pleasant_camera_tilt=scores["pleasant_camera_tilt"],
|
||||
pleasant_composition=scores["pleasant_composition"],
|
||||
pleasant_lighting=scores["pleasant_lighting"],
|
||||
pleasant_pattern=scores["pleasant_pattern"],
|
||||
pleasant_perspective=scores["pleasant_perspective"],
|
||||
pleasant_post_processing=scores["pleasant_post_processing"],
|
||||
pleasant_reflection=scores["pleasant_reflection"],
|
||||
pleasant_symmetry=scores["pleasant_symmetry"],
|
||||
sharply_focused_subject=scores["sharply_focused_subject"],
|
||||
tastefully_blurred=scores["tastefully_blurred"],
|
||||
well_chosen_subject=scores["well_chosen_subject"],
|
||||
well_framed_subject=scores["well_framed_subject"],
|
||||
well_timed_shot=scores["well_timed_shot"],
|
||||
)
|
||||
return self._scoreinfo
|
||||
except KeyError:
|
||||
self._scoreinfo = ScoreInfo(
|
||||
overall=0.0,
|
||||
curation=0.0,
|
||||
promotion=0.0,
|
||||
highlight_visibility=0.0,
|
||||
behavioral=0.0,
|
||||
failure=0.0,
|
||||
harmonious_color=0.0,
|
||||
immersiveness=0.0,
|
||||
interaction=0.0,
|
||||
interesting_subject=0.0,
|
||||
intrusive_object_presence=0.0,
|
||||
lively_color=0.0,
|
||||
low_light=0.0,
|
||||
noise=0.0,
|
||||
pleasant_camera_tilt=0.0,
|
||||
pleasant_composition=0.0,
|
||||
pleasant_lighting=0.0,
|
||||
pleasant_pattern=0.0,
|
||||
pleasant_perspective=0.0,
|
||||
pleasant_post_processing=0.0,
|
||||
pleasant_reflection=0.0,
|
||||
pleasant_symmetry=0.0,
|
||||
sharply_focused_subject=0.0,
|
||||
tastefully_blurred=0.0,
|
||||
well_chosen_subject=0.0,
|
||||
well_framed_subject=0.0,
|
||||
well_timed_shot=0.0,
|
||||
)
|
||||
return self._scoreinfo
|
||||
@@ -30,6 +30,7 @@ import Photos
|
||||
import Quartz
|
||||
from Foundation import NSNotificationCenter, NSObject
|
||||
from PyObjCTools import AppHelper
|
||||
from wurlitzer import pipes
|
||||
|
||||
from .fileutil import FileUtil
|
||||
from .uti import get_preferred_uti_extension
|
||||
@@ -495,69 +496,74 @@ class PhotoAsset:
|
||||
"""
|
||||
|
||||
with objc.autorelease_pool():
|
||||
filename = (
|
||||
pathlib.Path(filename)
|
||||
if filename
|
||||
else pathlib.Path(self.original_filename)
|
||||
)
|
||||
with pipes() as (out, err):
|
||||
filename = (
|
||||
pathlib.Path(filename)
|
||||
if filename
|
||||
else pathlib.Path(self.original_filename)
|
||||
)
|
||||
|
||||
dest = pathlib.Path(dest)
|
||||
if not dest.is_dir():
|
||||
raise ValueError("dest must be a valid directory: {dest}")
|
||||
dest = pathlib.Path(dest)
|
||||
if not dest.is_dir():
|
||||
raise ValueError("dest must be a valid directory: {dest}")
|
||||
|
||||
output_file = None
|
||||
if self.isphoto:
|
||||
# will hold exported image data and needs to be cleaned up at end
|
||||
imagedata = None
|
||||
if raw:
|
||||
# export the raw component
|
||||
resources = self._resources()
|
||||
for resource in resources:
|
||||
if resource.type() == Photos.PHAssetResourceTypeAlternatePhoto:
|
||||
data = self._request_resource_data(resource)
|
||||
ext = pathlib.Path(self.raw_filename).suffix[1:]
|
||||
break
|
||||
output_file = None
|
||||
if self.isphoto:
|
||||
# will hold exported image data and needs to be cleaned up at end
|
||||
imagedata = None
|
||||
if raw:
|
||||
# export the raw component
|
||||
resources = self._resources()
|
||||
for resource in resources:
|
||||
if (
|
||||
resource.type()
|
||||
== Photos.PHAssetResourceTypeAlternatePhoto
|
||||
):
|
||||
data = self._request_resource_data(resource)
|
||||
suffix = pathlib.Path(self.raw_filename).suffix
|
||||
ext = suffix[1:] if suffix else ""
|
||||
break
|
||||
else:
|
||||
raise PhotoKitExportError(
|
||||
"Could not get image data for RAW photo"
|
||||
)
|
||||
else:
|
||||
raise PhotoKitExportError(
|
||||
"Could not get image data for RAW photo"
|
||||
)
|
||||
else:
|
||||
# TODO: if user has selected use RAW as original, this returns the RAW
|
||||
# can get the jpeg with resource.type() == Photos.PHAssetResourceTypePhoto
|
||||
imagedata = self._request_image_data(version=version)
|
||||
if not imagedata.image_data:
|
||||
raise PhotoKitExportError("Could not get image data")
|
||||
ext = get_preferred_uti_extension(imagedata.uti)
|
||||
data = imagedata.image_data
|
||||
# TODO: if user has selected use RAW as original, this returns the RAW
|
||||
# can get the jpeg with resource.type() == Photos.PHAssetResourceTypePhoto
|
||||
imagedata = self._request_image_data(version=version)
|
||||
if not imagedata.image_data:
|
||||
raise PhotoKitExportError("Could not get image data")
|
||||
ext = get_preferred_uti_extension(imagedata.uti)
|
||||
data = imagedata.image_data
|
||||
|
||||
output_file = dest / f"{filename.stem}.{ext}"
|
||||
output_file = dest / f"{filename.stem}.{ext}"
|
||||
|
||||
if not overwrite:
|
||||
output_file = pathlib.Path(increment_filename(output_file))
|
||||
if not overwrite:
|
||||
output_file = pathlib.Path(increment_filename(output_file))
|
||||
|
||||
with open(output_file, "wb") as fd:
|
||||
fd.write(data)
|
||||
with open(output_file, "wb") as fd:
|
||||
fd.write(data)
|
||||
|
||||
if imagedata:
|
||||
del imagedata
|
||||
elif self.ismovie:
|
||||
videodata = self._request_video_data(version=version)
|
||||
if videodata.asset is None:
|
||||
raise PhotoKitExportError("Could not get video for asset")
|
||||
if imagedata:
|
||||
del imagedata
|
||||
elif self.ismovie:
|
||||
videodata = self._request_video_data(version=version)
|
||||
if videodata.asset is None:
|
||||
raise PhotoKitExportError("Could not get video for asset")
|
||||
|
||||
url = videodata.asset.URL()
|
||||
path = pathlib.Path(NSURL_to_path(url))
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError("Could not get path to video file")
|
||||
ext = path.suffix
|
||||
output_file = dest / f"{filename.stem}{ext}"
|
||||
url = videodata.asset.URL()
|
||||
path = pathlib.Path(NSURL_to_path(url))
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError("Could not get path to video file")
|
||||
ext = path.suffix
|
||||
output_file = dest / f"{filename.stem}{ext}"
|
||||
|
||||
if not overwrite:
|
||||
output_file = pathlib.Path(increment_filename(output_file))
|
||||
if not overwrite:
|
||||
output_file = pathlib.Path(increment_filename(output_file))
|
||||
|
||||
FileUtil.copy(path, output_file)
|
||||
FileUtil.copy(path, output_file)
|
||||
|
||||
return [str(output_file)]
|
||||
return [str(output_file)]
|
||||
|
||||
def _request_image_data(self, version=PHOTOS_VERSION_ORIGINAL):
|
||||
"""Request image data and metadata for self._phasset
|
||||
@@ -807,42 +813,46 @@ class VideoAsset(PhotoAsset):
|
||||
"""
|
||||
|
||||
with objc.autorelease_pool():
|
||||
if self.slow_mo and version == PHOTOS_VERSION_CURRENT:
|
||||
return [
|
||||
self._export_slow_mo(
|
||||
dest, filename=filename, version=version, overwrite=overwrite
|
||||
)
|
||||
]
|
||||
with pipes() as (out, err):
|
||||
if self.slow_mo and version == PHOTOS_VERSION_CURRENT:
|
||||
return [
|
||||
self._export_slow_mo(
|
||||
dest,
|
||||
filename=filename,
|
||||
version=version,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
]
|
||||
|
||||
filename = (
|
||||
pathlib.Path(filename)
|
||||
if filename
|
||||
else pathlib.Path(self.original_filename)
|
||||
)
|
||||
filename = (
|
||||
pathlib.Path(filename)
|
||||
if filename
|
||||
else pathlib.Path(self.original_filename)
|
||||
)
|
||||
|
||||
dest = pathlib.Path(dest)
|
||||
if not dest.is_dir():
|
||||
raise ValueError("dest must be a valid directory: {dest}")
|
||||
dest = pathlib.Path(dest)
|
||||
if not dest.is_dir():
|
||||
raise ValueError("dest must be a valid directory: {dest}")
|
||||
|
||||
output_file = None
|
||||
videodata = self._request_video_data(version=version)
|
||||
if videodata.asset is None:
|
||||
raise PhotoKitExportError("Could not get video for asset")
|
||||
output_file = None
|
||||
videodata = self._request_video_data(version=version)
|
||||
if videodata.asset is None:
|
||||
raise PhotoKitExportError("Could not get video for asset")
|
||||
|
||||
url = videodata.asset.URL()
|
||||
path = pathlib.Path(NSURL_to_path(url))
|
||||
del videodata
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError("Could not get path to video file")
|
||||
ext = path.suffix
|
||||
output_file = dest / f"{filename.stem}{ext}"
|
||||
url = videodata.asset.URL()
|
||||
path = pathlib.Path(NSURL_to_path(url))
|
||||
del videodata
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError("Could not get path to video file")
|
||||
ext = path.suffix
|
||||
output_file = dest / f"{filename.stem}{ext}"
|
||||
|
||||
if not overwrite:
|
||||
output_file = pathlib.Path(increment_filename(output_file))
|
||||
if not overwrite:
|
||||
output_file = pathlib.Path(increment_filename(output_file))
|
||||
|
||||
FileUtil.copy(path, output_file)
|
||||
FileUtil.copy(path, output_file)
|
||||
|
||||
return [str(output_file)]
|
||||
return [str(output_file)]
|
||||
|
||||
def _export_slow_mo(
|
||||
self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False
|
||||
@@ -1053,64 +1063,69 @@ class LivePhotoAsset(PhotoAsset):
|
||||
"""
|
||||
|
||||
with objc.autorelease_pool():
|
||||
filename = (
|
||||
pathlib.Path(filename)
|
||||
if filename
|
||||
else pathlib.Path(self.original_filename)
|
||||
)
|
||||
|
||||
dest = pathlib.Path(dest)
|
||||
if not dest.is_dir():
|
||||
raise ValueError("dest must be a valid directory: {dest}")
|
||||
|
||||
request = LivePhotoRequest.alloc().initWithManager_Asset_(
|
||||
self._manager, self.phasset
|
||||
)
|
||||
resources = request.requestLivePhotoResources(version=version)
|
||||
|
||||
video_resource = None
|
||||
photo_resource = None
|
||||
for resource in resources:
|
||||
if resource.type() == Photos.PHAssetResourceTypePairedVideo:
|
||||
video_resource = resource
|
||||
elif resource.type() == Photos.PHAssetMediaTypeImage:
|
||||
photo_resource = resource
|
||||
|
||||
if not video_resource or not photo_resource:
|
||||
raise PhotoKitExportError(
|
||||
"Did not find photo/video resources for live photo"
|
||||
with pipes() as (out, err):
|
||||
filename = (
|
||||
pathlib.Path(filename)
|
||||
if filename
|
||||
else pathlib.Path(self.original_filename)
|
||||
)
|
||||
|
||||
photo_ext = get_preferred_uti_extension(
|
||||
photo_resource.uniformTypeIdentifier()
|
||||
)
|
||||
photo_output_file = dest / f"{filename.stem}.{photo_ext}"
|
||||
video_ext = get_preferred_uti_extension(
|
||||
video_resource.uniformTypeIdentifier()
|
||||
)
|
||||
video_output_file = dest / f"{filename.stem}.{video_ext}"
|
||||
dest = pathlib.Path(dest)
|
||||
if not dest.is_dir():
|
||||
raise ValueError("dest must be a valid directory: {dest}")
|
||||
|
||||
if not overwrite:
|
||||
photo_output_file = pathlib.Path(increment_filename(photo_output_file))
|
||||
video_output_file = pathlib.Path(increment_filename(video_output_file))
|
||||
request = LivePhotoRequest.alloc().initWithManager_Asset_(
|
||||
self._manager, self.phasset
|
||||
)
|
||||
resources = request.requestLivePhotoResources(version=version)
|
||||
|
||||
exported = []
|
||||
if photo:
|
||||
data = self._request_resource_data(photo_resource)
|
||||
# image_data = self.request_image_data(version=version)
|
||||
with open(photo_output_file, "wb") as fd:
|
||||
fd.write(data)
|
||||
exported.append(str(photo_output_file))
|
||||
del data
|
||||
if video:
|
||||
data = self._request_resource_data(video_resource)
|
||||
with open(video_output_file, "wb") as fd:
|
||||
fd.write(data)
|
||||
exported.append(str(video_output_file))
|
||||
del data
|
||||
video_resource = None
|
||||
photo_resource = None
|
||||
for resource in resources:
|
||||
if resource.type() == Photos.PHAssetResourceTypePairedVideo:
|
||||
video_resource = resource
|
||||
elif resource.type() == Photos.PHAssetMediaTypeImage:
|
||||
photo_resource = resource
|
||||
|
||||
request.dealloc()
|
||||
return exported
|
||||
if not video_resource or not photo_resource:
|
||||
raise PhotoKitExportError(
|
||||
"Did not find photo/video resources for live photo"
|
||||
)
|
||||
|
||||
photo_ext = get_preferred_uti_extension(
|
||||
photo_resource.uniformTypeIdentifier()
|
||||
)
|
||||
photo_output_file = dest / f"{filename.stem}.{photo_ext}"
|
||||
video_ext = get_preferred_uti_extension(
|
||||
video_resource.uniformTypeIdentifier()
|
||||
)
|
||||
video_output_file = dest / f"{filename.stem}.{video_ext}"
|
||||
|
||||
if not overwrite:
|
||||
photo_output_file = pathlib.Path(
|
||||
increment_filename(photo_output_file)
|
||||
)
|
||||
video_output_file = pathlib.Path(
|
||||
increment_filename(video_output_file)
|
||||
)
|
||||
|
||||
exported = []
|
||||
if photo:
|
||||
data = self._request_resource_data(photo_resource)
|
||||
# image_data = self.request_image_data(version=version)
|
||||
with open(photo_output_file, "wb") as fd:
|
||||
fd.write(data)
|
||||
exported.append(str(photo_output_file))
|
||||
del data
|
||||
if video:
|
||||
data = self._request_resource_data(video_resource)
|
||||
with open(video_output_file, "wb") as fd:
|
||||
fd.write(data)
|
||||
exported.append(str(video_output_file))
|
||||
del data
|
||||
|
||||
request.dealloc()
|
||||
return exported
|
||||
|
||||
|
||||
class PhotoLibrary:
|
||||
|
||||
@@ -27,11 +27,11 @@ from .._constants import (
|
||||
_PHOTO_TYPE,
|
||||
_PHOTOS_3_VERSION,
|
||||
_PHOTOS_4_ALBUM_KIND,
|
||||
_PHOTOS_4_ROOT_FOLDER,
|
||||
_PHOTOS_4_TOP_LEVEL_ALBUMS,
|
||||
_PHOTOS_4_ALBUM_TYPE_ALBUM,
|
||||
_PHOTOS_4_ALBUM_TYPE_PROJECT,
|
||||
_PHOTOS_4_ALBUM_TYPE_SLIDESHOW,
|
||||
_PHOTOS_4_ROOT_FOLDER,
|
||||
_PHOTOS_4_TOP_LEVEL_ALBUMS,
|
||||
_PHOTOS_4_VERSION,
|
||||
_PHOTOS_5_ALBUM_KIND,
|
||||
_PHOTOS_5_FOLDER_KIND,
|
||||
@@ -42,6 +42,7 @@ from .._constants import (
|
||||
_TESTED_OS_VERSIONS,
|
||||
_UNKNOWN_PERSON,
|
||||
BURST_KEY,
|
||||
BURST_PICK_TYPE_NONE,
|
||||
BURST_SELECTED,
|
||||
TIME_DELTA,
|
||||
)
|
||||
@@ -3062,6 +3063,7 @@ class PhotosDB:
|
||||
if self._dbphotos[p]["burst"] and not (
|
||||
self._dbphotos[p]["burstPickType"] & BURST_SELECTED
|
||||
or self._dbphotos[p]["burstPickType"] & BURST_KEY
|
||||
or self._dbphotos[p]["burstPickType"] == BURST_PICK_TYPE_NONE
|
||||
):
|
||||
# not a key/selected burst photo, don't include in returned results
|
||||
continue
|
||||
|
||||
@@ -375,7 +375,9 @@ class PhotoTemplate:
|
||||
self.filepath = options.filepath
|
||||
self.quote = options.quote
|
||||
self.dest_path = options.dest_path
|
||||
self.exportdb = options.exportdb or ExportDBInMemory(None)
|
||||
self.exportdb = options.exportdb or ExportDBInMemory(
|
||||
None, self.export_dir or "."
|
||||
)
|
||||
|
||||
def render(
|
||||
self,
|
||||
|
||||
39
osxphotos/scoreinfo.py
Normal file
39
osxphotos/scoreinfo.py
Normal file
@@ -0,0 +1,39 @@
|
||||
""" ScoreInfo class to expose computed score info from the library """
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ._constants import _PHOTOS_4_VERSION
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScoreInfo:
|
||||
""" Computed photo score info associated with a photo from the Photos library """
|
||||
|
||||
overall: float
|
||||
curation: float
|
||||
promotion: float
|
||||
highlight_visibility: float
|
||||
behavioral: float
|
||||
failure: float
|
||||
harmonious_color: float
|
||||
immersiveness: float
|
||||
interaction: float
|
||||
interesting_subject: float
|
||||
intrusive_object_presence: float
|
||||
lively_color: float
|
||||
low_light: float
|
||||
noise: float
|
||||
pleasant_camera_tilt: float
|
||||
pleasant_composition: float
|
||||
pleasant_lighting: float
|
||||
pleasant_pattern: float
|
||||
pleasant_perspective: float
|
||||
pleasant_post_processing: float
|
||||
pleasant_reflection: float
|
||||
pleasant_symmetry: float
|
||||
sharply_focused_subject: float
|
||||
tastefully_blurred: float
|
||||
well_chosen_subject: float
|
||||
well_framed_subject: float
|
||||
well_timed_shot: float
|
||||
|
||||
@@ -1,98 +1,39 @@
|
||||
""" Methods and class for PhotoInfo exposing SearchInfo data such as labels
|
||||
Adds the following properties to PhotoInfo (valid only for Photos 5):
|
||||
search_info: returns a SearchInfo object
|
||||
search_info_normalized: returns a SearchInfo object with properties that produce normalized results
|
||||
labels: returns list of labels
|
||||
labels_normalized: returns list of normalized labels
|
||||
""" class for PhotoInfo exposing SearchInfo data such as labels
|
||||
"""
|
||||
|
||||
from .._constants import (
|
||||
from ._constants import (
|
||||
_PHOTOS_4_VERSION,
|
||||
SEARCH_CATEGORY_ACTIVITY,
|
||||
SEARCH_CATEGORY_ALL_LOCALITY,
|
||||
SEARCH_CATEGORY_BODY_OF_WATER,
|
||||
SEARCH_CATEGORY_CITY,
|
||||
SEARCH_CATEGORY_COUNTRY,
|
||||
SEARCH_CATEGORY_HOLIDAY,
|
||||
SEARCH_CATEGORY_LABEL,
|
||||
SEARCH_CATEGORY_MEDIA_TYPES,
|
||||
SEARCH_CATEGORY_MONTH,
|
||||
SEARCH_CATEGORY_NEIGHBORHOOD,
|
||||
SEARCH_CATEGORY_PLACE_NAME,
|
||||
SEARCH_CATEGORY_STREET,
|
||||
SEARCH_CATEGORY_ALL_LOCALITY,
|
||||
SEARCH_CATEGORY_COUNTRY,
|
||||
SEARCH_CATEGORY_SEASON,
|
||||
SEARCH_CATEGORY_STATE,
|
||||
SEARCH_CATEGORY_STATE_ABBREVIATION,
|
||||
SEARCH_CATEGORY_BODY_OF_WATER,
|
||||
SEARCH_CATEGORY_MONTH,
|
||||
SEARCH_CATEGORY_YEAR,
|
||||
SEARCH_CATEGORY_HOLIDAY,
|
||||
SEARCH_CATEGORY_ACTIVITY,
|
||||
SEARCH_CATEGORY_SEASON,
|
||||
SEARCH_CATEGORY_STREET,
|
||||
SEARCH_CATEGORY_VENUE,
|
||||
SEARCH_CATEGORY_VENUE_TYPE,
|
||||
SEARCH_CATEGORY_MEDIA_TYPES,
|
||||
SEARCH_CATEGORY_YEAR,
|
||||
)
|
||||
|
||||
|
||||
@property
|
||||
def search_info(self):
|
||||
""" returns SearchInfo object for photo
|
||||
only valid on Photos 5, on older libraries, returns None
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return None
|
||||
|
||||
# memoize SearchInfo object
|
||||
try:
|
||||
return self._search_info
|
||||
except AttributeError:
|
||||
self._search_info = SearchInfo(self)
|
||||
return self._search_info
|
||||
|
||||
|
||||
@property
|
||||
def search_info_normalized(self):
|
||||
""" returns SearchInfo object for photo that produces normalized results
|
||||
only valid on Photos 5, on older libraries, returns None
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return None
|
||||
|
||||
# memoize SearchInfo object
|
||||
try:
|
||||
return self._search_info_normalized
|
||||
except AttributeError:
|
||||
self._search_info_normalized = SearchInfo(self, normalized=True)
|
||||
return self._search_info_normalized
|
||||
|
||||
|
||||
@property
|
||||
def labels(self):
|
||||
""" returns list of labels applied to photo by Photos image categorization
|
||||
only valid on Photos 5, on older libraries returns empty list
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return []
|
||||
|
||||
return self.search_info.labels
|
||||
|
||||
|
||||
@property
|
||||
def labels_normalized(self):
|
||||
""" returns normalized list of labels applied to photo by Photos image categorization
|
||||
only valid on Photos 5, on older libraries returns empty list
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return []
|
||||
|
||||
return self.search_info_normalized.labels
|
||||
|
||||
|
||||
class SearchInfo:
|
||||
""" Info about search terms such as machine learning labels that Photos knows about a photo """
|
||||
"""Info about search terms such as machine learning labels that Photos knows about a photo"""
|
||||
|
||||
def __init__(self, photo, normalized=False):
|
||||
""" photo: PhotoInfo object
|
||||
normalized: if True, all properties return normalized (lower case) results """
|
||||
"""photo: PhotoInfo object
|
||||
normalized: if True, all properties return normalized (lower case) results"""
|
||||
|
||||
if photo._db._db_version <= _PHOTOS_4_VERSION:
|
||||
raise NotImplementedError(
|
||||
f"search info not implemented for this database version"
|
||||
"search info not implemented for this database version"
|
||||
)
|
||||
|
||||
self._photo = photo
|
||||
@@ -107,27 +48,27 @@ class SearchInfo:
|
||||
|
||||
@property
|
||||
def labels(self):
|
||||
""" return list of labels associated with Photo """
|
||||
"""return list of labels associated with Photo"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_LABEL)
|
||||
|
||||
@property
|
||||
def place_names(self):
|
||||
""" returns list of place names """
|
||||
"""returns list of place names"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_PLACE_NAME)
|
||||
|
||||
@property
|
||||
def streets(self):
|
||||
""" returns list of street names """
|
||||
"""returns list of street names"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_STREET)
|
||||
|
||||
@property
|
||||
def neighborhoods(self):
|
||||
""" returns list of neighborhoods """
|
||||
"""returns list of neighborhoods"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_NEIGHBORHOOD)
|
||||
|
||||
@property
|
||||
def locality_names(self):
|
||||
""" returns list of other locality names """
|
||||
"""returns list of other locality names"""
|
||||
locality = []
|
||||
for category in SEARCH_CATEGORY_ALL_LOCALITY:
|
||||
locality += self._get_text_for_category(category)
|
||||
@@ -135,74 +76,74 @@ class SearchInfo:
|
||||
|
||||
@property
|
||||
def city(self):
|
||||
""" returns city/town """
|
||||
"""returns city/town"""
|
||||
city = self._get_text_for_category(SEARCH_CATEGORY_CITY)
|
||||
return city[0] if city else ""
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" returns state name """
|
||||
"""returns state name"""
|
||||
state = self._get_text_for_category(SEARCH_CATEGORY_STATE)
|
||||
return state[0] if state else ""
|
||||
|
||||
@property
|
||||
def state_abbreviation(self):
|
||||
""" returns state abbreviation """
|
||||
"""returns state abbreviation"""
|
||||
abbrev = self._get_text_for_category(SEARCH_CATEGORY_STATE_ABBREVIATION)
|
||||
return abbrev[0] if abbrev else ""
|
||||
|
||||
@property
|
||||
def country(self):
|
||||
""" returns country name """
|
||||
"""returns country name"""
|
||||
country = self._get_text_for_category(SEARCH_CATEGORY_COUNTRY)
|
||||
return country[0] if country else ""
|
||||
|
||||
@property
|
||||
def month(self):
|
||||
""" returns month name """
|
||||
"""returns month name"""
|
||||
month = self._get_text_for_category(SEARCH_CATEGORY_MONTH)
|
||||
return month[0] if month else ""
|
||||
|
||||
@property
|
||||
def year(self):
|
||||
""" returns year """
|
||||
"""returns year"""
|
||||
year = self._get_text_for_category(SEARCH_CATEGORY_YEAR)
|
||||
return year[0] if year else ""
|
||||
|
||||
@property
|
||||
def bodies_of_water(self):
|
||||
""" returns list of body of water names """
|
||||
"""returns list of body of water names"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_BODY_OF_WATER)
|
||||
|
||||
@property
|
||||
def holidays(self):
|
||||
""" returns list of holiday names """
|
||||
"""returns list of holiday names"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_HOLIDAY)
|
||||
|
||||
@property
|
||||
def activities(self):
|
||||
""" returns list of activity names """
|
||||
"""returns list of activity names"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_ACTIVITY)
|
||||
|
||||
@property
|
||||
def season(self):
|
||||
""" returns season name """
|
||||
"""returns season name"""
|
||||
season = self._get_text_for_category(SEARCH_CATEGORY_SEASON)
|
||||
return season[0] if season else ""
|
||||
|
||||
@property
|
||||
def venues(self):
|
||||
""" returns list of venue names """
|
||||
"""returns list of venue names"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_VENUE)
|
||||
|
||||
@property
|
||||
def venue_types(self):
|
||||
""" returns list of venue types """
|
||||
"""returns list of venue types"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_VENUE_TYPE)
|
||||
|
||||
@property
|
||||
def media_types(self):
|
||||
""" returns list of media types (photo, video, panorama, etc) """
|
||||
"""returns list of media types (photo, video, panorama, etc)"""
|
||||
types = []
|
||||
for category in SEARCH_CATEGORY_MEDIA_TYPES:
|
||||
types += self._get_text_for_category(category)
|
||||
@@ -210,7 +151,7 @@ class SearchInfo:
|
||||
|
||||
@property
|
||||
def all(self):
|
||||
""" return all search info properties in a single list """
|
||||
"""return all search info properties in a single list"""
|
||||
all = (
|
||||
self.labels
|
||||
+ self.place_names
|
||||
@@ -242,7 +183,7 @@ class SearchInfo:
|
||||
return all
|
||||
|
||||
def asdict(self):
|
||||
""" return dict of search info """
|
||||
"""return dict of search info"""
|
||||
return {
|
||||
"labels": self.labels,
|
||||
"place_names": self.place_names,
|
||||
@@ -265,7 +206,7 @@ class SearchInfo:
|
||||
}
|
||||
|
||||
def _get_text_for_category(self, category):
|
||||
""" return list of text for a specified category ID """
|
||||
"""return list of text for a specified category ID"""
|
||||
if self._db_searchinfo:
|
||||
content = "normalized_string" if self._normalized else "content_string"
|
||||
return [
|
||||
@@ -591,6 +591,9 @@ def get_preferred_uti_extension(uti):
|
||||
def get_uti_for_extension(extension):
|
||||
"""get UTI for a given file extension"""
|
||||
|
||||
if not extension:
|
||||
return None
|
||||
|
||||
# accepts extension with or without leading 0
|
||||
if extension[0] == ".":
|
||||
extension = extension[1:]
|
||||
|
||||
@@ -13,6 +13,7 @@ import pytest
|
||||
|
||||
import osxphotos
|
||||
from osxphotos._constants import _UNKNOWN_PERSON
|
||||
from osxphotos.photoexporter import PhotoExporter
|
||||
from osxphotos.utils import _get_os_version
|
||||
|
||||
OS_VERSION = _get_os_version()
|
||||
@@ -1142,6 +1143,7 @@ def test_date_invalid():
|
||||
"""Test date is invalid"""
|
||||
# doesn't run correctly with the module-level fixture
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
@@ -1396,7 +1398,7 @@ def test_exiftool_newlines_in_description(photosdb):
|
||||
"""Test that exiftool handles newlines embedded in description, issue #393"""
|
||||
|
||||
photo = photosdb.get_photo(UUID_DICT["description_newlines"])
|
||||
exif = photo._exiftool_dict()
|
||||
exif = PhotoExporter(photo)._exiftool_dict()
|
||||
assert photo.description.find("\n") > 0
|
||||
assert exif["EXIF:ImageDescription"].find("\n") > 0
|
||||
|
||||
|
||||
@@ -4291,7 +4291,7 @@ def test_export_error(monkeypatch):
|
||||
def throw_error(*args, **kwargs):
|
||||
raise ValueError("Argh!")
|
||||
|
||||
monkeypatch.setattr(osxphotos.PhotoInfo, "export2", throw_error)
|
||||
monkeypatch.setattr(osxphotos.PhotoExporter, "export2", throw_error)
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export,
|
||||
@@ -5610,7 +5610,7 @@ def test_export_ignore_signature_sidecar():
|
||||
|
||||
# change the sidecar data in export DB
|
||||
# should result in a new sidecar being exported but not the image itself
|
||||
exportdb = osxphotos.export_db.ExportDB("./.osxphotos_export.db")
|
||||
exportdb = osxphotos.export_db.ExportDB("./.osxphotos_export.db", ".")
|
||||
for filename in CLI_EXPORT_IGNORE_SIGNATURE_FILENAMES:
|
||||
exportdb.set_sidecar_for_file(f"{filename}.xmp", "FOO", (0, 1, 2))
|
||||
|
||||
@@ -6163,16 +6163,6 @@ def test_export_exportdb():
|
||||
in result.output
|
||||
)
|
||||
|
||||
# specify a path for exportdb, should generate error
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--exportdb", "./export.db"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert (
|
||||
"Error: --exportdb must be specified as filename not path" in result.output
|
||||
)
|
||||
|
||||
|
||||
def test_export_finder_tag_keywords():
|
||||
"""test --finder-tag-keywords"""
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from osxphotos.photoinfo import ExifInfo
|
||||
from osxphotos.exifinfo import ExifInfo
|
||||
|
||||
PHOTOS_DB_5 = "tests/Test-Cloud-10.15.1.photoslibrary"
|
||||
PHOTOS_DB_4 = "tests/Test-10.14.6.photoslibrary"
|
||||
@@ -82,14 +82,14 @@ def photosdb():
|
||||
|
||||
|
||||
def test_exif_info_v5(photosdb):
|
||||
""" test exif_info """
|
||||
"""test exif_info"""
|
||||
for uuid in EXIF_DICT:
|
||||
photo = photosdb.photos(uuid=[uuid], movies=True)[0]
|
||||
assert photo.exif_info == EXIF_DICT[uuid]
|
||||
|
||||
|
||||
def test_exif_info_v4():
|
||||
""" test version 4, exif_info should be None """
|
||||
"""test version 4, exif_info should be None"""
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_4)
|
||||
|
||||
@@ -8,6 +8,7 @@ import pytest
|
||||
import osxphotos
|
||||
from osxphotos._constants import _UNKNOWN_PERSON
|
||||
from osxphotos.exiftool import get_exiftool_path
|
||||
from osxphotos.photoexporter import PhotoExporter
|
||||
from osxphotos.utils import dd_to_dms_str
|
||||
|
||||
# determine if exiftool installed so exiftool tests can be skipped
|
||||
@@ -401,7 +402,7 @@ def test_exiftool_json_sidecar(photosdb):
|
||||
with open(str(pathlib.Path(SIDECAR_DIR) / f"{uuid}.json"), "r") as fp:
|
||||
json_expected = json.load(fp)[0]
|
||||
|
||||
json_got = photo._exiftool_json_sidecar()
|
||||
json_got = PhotoExporter(photo)._exiftool_json_sidecar()
|
||||
json_got = json.loads(json_got)[0]
|
||||
|
||||
assert json_got == json_expected
|
||||
@@ -417,7 +418,7 @@ def test_exiftool_json_sidecar_ignore_date_modified(photosdb):
|
||||
) as fp:
|
||||
json_expected = json.load(fp)[0]
|
||||
|
||||
json_got = photo._exiftool_json_sidecar(ignore_date_modified=True)
|
||||
json_got = PhotoExporter(photo)._exiftool_json_sidecar(ignore_date_modified=True)
|
||||
json_got = json.loads(json_got)[0]
|
||||
|
||||
assert json_got == json_expected
|
||||
@@ -448,7 +449,7 @@ def test_exiftool_json_sidecar_keyword_template_long(capsys, photosdb):
|
||||
|
||||
long_str = "x" * (_MAX_IPTC_KEYWORD_LEN + 1)
|
||||
photos[0]._verbose = print
|
||||
json_got = photos[0]._exiftool_json_sidecar(keyword_template=[long_str])
|
||||
json_got = PhotoExporter(photos[0])._exiftool_json_sidecar(keyword_template=[long_str])
|
||||
json_got = json.loads(json_got)[0]
|
||||
|
||||
captured = capsys.readouterr()
|
||||
@@ -483,7 +484,7 @@ def test_exiftool_json_sidecar_keyword_template(photosdb):
|
||||
str(pathlib.Path(SIDECAR_DIR) / f"{uuid}_keyword_template.json"), "r"
|
||||
) as fp:
|
||||
json_expected = json.load(fp)
|
||||
json_got = photo._exiftool_json_sidecar(keyword_template=["{folder_album}"])
|
||||
json_got = PhotoExporter(photo)._exiftool_json_sidecar(keyword_template=["{folder_album}"])
|
||||
json_got = json.loads(json_got)
|
||||
|
||||
assert json_got == json_expected
|
||||
@@ -499,7 +500,7 @@ def test_exiftool_json_sidecar_use_persons_keyword(photosdb):
|
||||
) as fp:
|
||||
json_expected = json.load(fp)[0]
|
||||
|
||||
json_got = photo._exiftool_json_sidecar(use_persons_as_keywords=True)
|
||||
json_got = PhotoExporter(photo)._exiftool_json_sidecar(use_persons_as_keywords=True)
|
||||
json_got = json.loads(json_got)[0]
|
||||
|
||||
assert json_got == json_expected
|
||||
@@ -515,7 +516,7 @@ def test_exiftool_json_sidecar_use_albums_keywords(photosdb):
|
||||
) as fp:
|
||||
json_expected = json.load(fp)
|
||||
|
||||
json_got = photo._exiftool_json_sidecar(use_albums_as_keywords=True)
|
||||
json_got = PhotoExporter(photo)._exiftool_json_sidecar(use_albums_as_keywords=True)
|
||||
json_got = json.loads(json_got)
|
||||
|
||||
assert json_got == json_expected
|
||||
@@ -528,7 +529,7 @@ def test_exiftool_sidecar(photosdb):
|
||||
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_no_tag_groups.json", "r") as fp:
|
||||
json_expected = fp.read()
|
||||
|
||||
json_got = photo._exiftool_json_sidecar(tag_groups=False)
|
||||
json_got = PhotoExporter(photo)._exiftool_json_sidecar(tag_groups=False)
|
||||
|
||||
assert json_got == json_expected
|
||||
|
||||
@@ -554,7 +555,7 @@ def test_xmp_sidecar(photosdb):
|
||||
|
||||
with open(f"tests/sidecars/{uuid}.xmp", "r") as file:
|
||||
xmp_expected = file.read()
|
||||
xmp_got = photos[0]._xmp_sidecar(extension="jpg")
|
||||
xmp_got = PhotoExporter(photos[0])._xmp_sidecar(extension="jpg")
|
||||
assert xmp_got == xmp_expected
|
||||
|
||||
|
||||
@@ -568,7 +569,7 @@ def test_xmp_sidecar_extension(photosdb):
|
||||
xmp_expected = file.read()
|
||||
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
|
||||
|
||||
xmp_got = photos[0]._xmp_sidecar()
|
||||
xmp_got = PhotoExporter(photos[0])._xmp_sidecar()
|
||||
assert xmp_got == xmp_expected
|
||||
|
||||
|
||||
@@ -580,7 +581,7 @@ def test_xmp_sidecar_use_persons_keyword(photosdb):
|
||||
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_persons_as_keywords.xmp") as fp:
|
||||
xmp_expected = fp.read()
|
||||
|
||||
xmp_got = photo._xmp_sidecar(use_persons_as_keywords=True, extension="jpg")
|
||||
xmp_got = PhotoExporter(photo)._xmp_sidecar(use_persons_as_keywords=True, extension="jpg")
|
||||
assert xmp_got == xmp_expected
|
||||
|
||||
|
||||
@@ -592,7 +593,7 @@ def test_xmp_sidecar_use_albums_keyword(photosdb):
|
||||
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_albums_as_keywords.xmp") as fp:
|
||||
xmp_expected = fp.read()
|
||||
|
||||
xmp_got = photo._xmp_sidecar(use_albums_as_keywords=True, extension="jpg")
|
||||
xmp_got = PhotoExporter(photo)._xmp_sidecar(use_albums_as_keywords=True, extension="jpg")
|
||||
assert xmp_got == xmp_expected
|
||||
|
||||
|
||||
@@ -605,7 +606,7 @@ def test_xmp_sidecar_gps(photosdb):
|
||||
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}.xmp") as fp:
|
||||
xmp_expected = fp.read()
|
||||
|
||||
xmp_got = photo._xmp_sidecar()
|
||||
xmp_got = PhotoExporter(photo)._xmp_sidecar()
|
||||
assert xmp_got == xmp_expected
|
||||
|
||||
|
||||
@@ -617,7 +618,7 @@ def test_xmp_sidecar_keyword_template(photosdb):
|
||||
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_keyword_template.xmp") as fp:
|
||||
xmp_expected = fp.read()
|
||||
|
||||
xmp_got = photo._xmp_sidecar(
|
||||
xmp_got = PhotoExporter(photo)._xmp_sidecar(
|
||||
keyword_template=["{created.year}", "{folder_album}"], extension="jpg"
|
||||
)
|
||||
assert xmp_got == xmp_expected
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import pytest
|
||||
|
||||
from osxphotos._constants import _UNKNOWN_PERSON
|
||||
from osxphotos.photoexporter import PhotoExporter
|
||||
|
||||
skip_test = "OSXPHOTOS_TEST_CONVERT" not in os.environ
|
||||
pytestmark = pytest.mark.skipif(
|
||||
@@ -43,7 +44,7 @@ def test_export_convert_raw_to_jpeg(photosdb):
|
||||
dest = tempdir.name
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["raw"]])
|
||||
|
||||
results = photos[0].export2(dest, convert_to_jpeg=True)
|
||||
results = PhotoExporter(photos[0]).export2(dest, convert_to_jpeg=True)
|
||||
got_dest = pathlib.Path(results.exported[0])
|
||||
|
||||
assert got_dest.is_file()
|
||||
@@ -60,7 +61,7 @@ def test_export_convert_heic_to_jpeg(photosdb):
|
||||
dest = tempdir.name
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["heic"]])
|
||||
|
||||
results = photos[0].export2(dest, convert_to_jpeg=True)
|
||||
results = PhotoExporter(photos[0]).export2(dest, convert_to_jpeg=True)
|
||||
got_dest = pathlib.Path(results.exported[0])
|
||||
|
||||
assert got_dest.is_file()
|
||||
@@ -87,7 +88,7 @@ def test_export_convert_live_heic_to_jpeg():
|
||||
dest = tempdir.name
|
||||
photo = photosdb.get_photo(UUID_LIVE_HEIC)
|
||||
|
||||
results = photo.export2(dest, convert_to_jpeg=True, live_photo=True)
|
||||
results = PhotoExporter(photo).export2(dest, convert_to_jpeg=True, live_photo=True)
|
||||
|
||||
for name in NAMES_LIVE_HEIC:
|
||||
assert f"{tempdir.name}/{name}" in results.exported
|
||||
|
||||
@@ -22,7 +22,7 @@ def test_export_db():
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dbname = os.path.join(tempdir.name, ".osxphotos_export.db")
|
||||
db = ExportDB(dbname)
|
||||
db = ExportDB(dbname, tempdir.name)
|
||||
assert os.path.isfile(dbname)
|
||||
assert db.was_created
|
||||
assert not db.was_upgraded
|
||||
@@ -76,7 +76,7 @@ def test_export_db():
|
||||
|
||||
# close and re-open
|
||||
db.close()
|
||||
db = ExportDB(dbname)
|
||||
db = ExportDB(dbname, tempdir.name)
|
||||
assert not db.was_created
|
||||
assert db.get_uuid_for_file(filepath2) == "BAR-FOO"
|
||||
assert db.get_info_for_uuid("BAR-FOO") == INFO_DATA
|
||||
@@ -131,7 +131,7 @@ def test_export_db_no_op():
|
||||
|
||||
db.set_detected_text_for_uuid("FOO-BAR", json.dumps([["foo", 0.5]]))
|
||||
assert db.get_detected_text_for_uuid("FOO-BAR") is None
|
||||
|
||||
|
||||
# test set_data which sets all at the same time
|
||||
filepath2 = os.path.join(tempdir.name, "test2.jpg")
|
||||
db.set_data(
|
||||
@@ -171,7 +171,7 @@ def test_export_db_in_memory():
|
||||
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
dbname = os.path.join(tempdir.name, ".osxphotos_export.db")
|
||||
db = ExportDB(dbname)
|
||||
db = ExportDB(dbname, tempdir.name)
|
||||
assert os.path.isfile(dbname)
|
||||
|
||||
filepath = os.path.join(tempdir.name, "test.JPG")
|
||||
@@ -190,7 +190,7 @@ def test_export_db_in_memory():
|
||||
|
||||
db.close()
|
||||
|
||||
dbram = ExportDBInMemory(dbname)
|
||||
dbram = ExportDBInMemory(dbname, tempdir.name)
|
||||
assert not dbram.was_created
|
||||
assert not dbram.was_upgraded
|
||||
assert dbram.version == OSXPHOTOS_EXPORTDB_VERSION
|
||||
@@ -232,7 +232,7 @@ def test_export_db_in_memory():
|
||||
dbram.close()
|
||||
|
||||
# re-open on disk and verify no changes
|
||||
db = ExportDB(dbname)
|
||||
db = ExportDB(dbname, tempdir.name)
|
||||
assert db.get_uuid_for_file(filepath_lower) == "FOO-BAR"
|
||||
assert db.get_info_for_uuid("FOO-BAR") == INFO_DATA
|
||||
assert db.get_exifdata_for_file(filepath) == EXIF_DATA
|
||||
@@ -258,7 +258,9 @@ def test_export_db_in_memory_nofile():
|
||||
filepath = os.path.join(tempdir.name, "test.JPG")
|
||||
filepath_lower = os.path.join(tempdir.name, "test.jpg")
|
||||
|
||||
dbram = ExportDBInMemory(os.path.join(tempdir.name, "NOT_A_DATABASE_FILE.db"))
|
||||
dbram = ExportDBInMemory(
|
||||
os.path.join(tempdir.name, "NOT_A_DATABASE_FILE.db"), tempdir.name
|
||||
)
|
||||
assert dbram.was_created
|
||||
assert not dbram.was_upgraded
|
||||
assert dbram.version == OSXPHOTOS_EXPORTDB_VERSION
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
import json
|
||||
import osxphotos
|
||||
from osxphotos._constants import _UNKNOWN_PERSON
|
||||
import pathlib
|
||||
from osxphotos.photoexporter import PhotoExporter
|
||||
|
||||
PHOTOS_DB = "./tests/Test-10.14.6.photoslibrary/database/photos.db"
|
||||
PHOTOS_DB_PATH = "/Test-10.14.6.photoslibrary/database/photos.db"
|
||||
@@ -335,7 +337,7 @@ def test_exiftool_json_sidecar(photosdb):
|
||||
with open(str(pathlib.Path(SIDECAR_DIR) / f"{uuid}.json"), "r") as fp:
|
||||
json_expected = json.load(fp)[0]
|
||||
|
||||
json_got = photo._exiftool_json_sidecar()
|
||||
json_got = PhotoExporter(photo)._exiftool_json_sidecar()
|
||||
json_got = json.loads(json_got)[0]
|
||||
|
||||
assert json_got == json_expected
|
||||
@@ -349,7 +351,7 @@ def test_xmp_sidecar(photosdb):
|
||||
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_ext.xmp") as fp:
|
||||
xmp_expected = fp.read()
|
||||
|
||||
xmp_got = photo._xmp_sidecar(extension="jpg")
|
||||
xmp_got = PhotoExporter(photo)._xmp_sidecar(extension="jpg")
|
||||
|
||||
assert xmp_got == xmp_expected
|
||||
|
||||
@@ -362,7 +364,7 @@ def test_xmp_sidecar_keyword_template(photosdb):
|
||||
with open(pathlib.Path(SIDECAR_DIR) / f"{uuid}_keyword_template.xmp") as fp:
|
||||
xmp_expected = fp.read()
|
||||
|
||||
xmp_got = photo._xmp_sidecar(
|
||||
xmp_got = PhotoExporter(photo)._xmp_sidecar(
|
||||
keyword_template=["{created.year}", "{folder_album}"], extension="jpg"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
""" test ExportResults class """
|
||||
|
||||
import pytest
|
||||
from osxphotos.photoinfo import ExportResults
|
||||
from osxphotos.photoexporter import ExportResults
|
||||
|
||||
EXPORT_RESULT_ATTRIBUTES = [
|
||||
"exported",
|
||||
|
||||
@@ -13,6 +13,7 @@ import pytest
|
||||
|
||||
import osxphotos
|
||||
from osxphotos._constants import _UNKNOWN_PERSON
|
||||
from osxphotos.photoexporter import PhotoExporter
|
||||
from osxphotos.utils import _get_os_version
|
||||
|
||||
OS_VERSION = _get_os_version()
|
||||
@@ -1109,6 +1110,7 @@ def test_date_invalid():
|
||||
"""Test date is invalid"""
|
||||
# doesn't run correctly with the module-level fixture
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
@@ -1349,7 +1351,7 @@ def test_exiftool_newlines_in_description(photosdb):
|
||||
"""Test that exiftool handles newlines embedded in description, issue #393"""
|
||||
|
||||
photo = photosdb.get_photo(UUID_DICT["description_newlines"])
|
||||
exif = photo._exiftool_dict()
|
||||
exif = PhotoExporter(photo)._exiftool_dict()
|
||||
assert photo.description.find("\n") > 0
|
||||
assert exif["EXIF:ImageDescription"].find("\n") > 0
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from math import isclose
|
||||
import pytest
|
||||
|
||||
from osxphotos.photoinfo import ScoreInfo
|
||||
from osxphotos.scoreinfo import ScoreInfo
|
||||
|
||||
PHOTOS_DB_5 = "tests/Test-10.15.5.photoslibrary"
|
||||
PHOTOS_DB_4 = "tests/Test-10.14.6.photoslibrary"
|
||||
|
||||
@@ -9,6 +9,7 @@ import osxphotos
|
||||
from osxphotos._constants import SIDECAR_XMP
|
||||
from osxphotos.exiftool import ExifTool, get_exiftool_path
|
||||
from osxphotos.fileutil import FileUtil
|
||||
from osxphotos.photoexporter import PhotoExporter
|
||||
|
||||
PHOTOS_DB_15_7 = "tests/Test-10.15.7.photoslibrary"
|
||||
|
||||
@@ -39,7 +40,7 @@ def test_sidecar_xmp(photosdb):
|
||||
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos")
|
||||
dest = tempdir.name
|
||||
photo = photosdb.get_photo(uuid)
|
||||
photo.export2(dest, photo.original_filename, sidecar=SIDECAR_XMP)
|
||||
PhotoExporter(photo).export2(dest, photo.original_filename, sidecar=SIDECAR_XMP)
|
||||
filepath = str(pathlib.Path(dest) / photo.original_filename)
|
||||
xmppath = filepath + ".xmp"
|
||||
|
||||
@@ -53,6 +54,8 @@ def test_sidecar_xmp(photosdb):
|
||||
test_xmp = str(pathlib.Path(dest) / "test.xmp")
|
||||
FileUtil.copy(xmppath, test_xmp)
|
||||
exif = ExifTool(test_xmp)
|
||||
output, warning, error = exif.run_commands("-tagsfromfile", xmppath, "-all:all", test_xmp, no_file=True)
|
||||
output, warning, error = exif.run_commands(
|
||||
"-tagsfromfile", xmppath, "-all:all", test_xmp, no_file=True
|
||||
)
|
||||
assert not warning
|
||||
assert not error
|
||||
|
||||
Reference in New Issue
Block a user