Feature add locations 929 (#932)
* Initial implementation of add-locations command #929 * Updated help for add-locations * Fixed handling of shared albums in sync * Added test for add-locations
This commit is contained in:
@@ -44,6 +44,7 @@ if args.get("--debug", False):
|
|||||||
print("Debugging enabled", file=sys.stderr)
|
print("Debugging enabled", file=sys.stderr)
|
||||||
|
|
||||||
from .about import about
|
from .about import about
|
||||||
|
from .add_locations import add_locations
|
||||||
from .albums import albums
|
from .albums import albums
|
||||||
from .cli import cli_main
|
from .cli import cli_main
|
||||||
from .common import get_photos_db, load_uuid_from_file
|
from .common import get_photos_db, load_uuid_from_file
|
||||||
@@ -73,6 +74,7 @@ install_traceback()
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"about",
|
"about",
|
||||||
|
"add_locations",
|
||||||
"albums",
|
"albums",
|
||||||
"cli_main",
|
"cli_main",
|
||||||
"debug_dump",
|
"debug_dump",
|
||||||
|
|||||||
190
osxphotos/cli/add_locations.py
Normal file
190
osxphotos/cli/add_locations.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""Add missing location data to photos in Photos.app using nearest neighbor."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import click
|
||||||
|
import photoscript
|
||||||
|
|
||||||
|
import osxphotos
|
||||||
|
from osxphotos.utils import pluralize
|
||||||
|
|
||||||
|
from .click_rich_echo import (
|
||||||
|
rich_click_echo,
|
||||||
|
rich_echo_error,
|
||||||
|
set_rich_console,
|
||||||
|
set_rich_theme,
|
||||||
|
set_rich_timestamp,
|
||||||
|
)
|
||||||
|
from .color_themes import get_theme
|
||||||
|
from .common import QUERY_OPTIONS, THEME_OPTION, query_options_from_kwargs
|
||||||
|
from .param_types import TimeOffset
|
||||||
|
from .rich_progress import rich_progress
|
||||||
|
from .verbose import get_verbose_console, verbose_print
|
||||||
|
|
||||||
|
|
||||||
|
def get_location(
|
||||||
|
photos: list[osxphotos.PhotoInfo], idx: int, window: datetime.timedelta
|
||||||
|
) -> osxphotos.PhotoInfo | None:
|
||||||
|
"""Find nearest neighbor with location data within window of time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
photo: PhotoInfo object
|
||||||
|
idx: index of photo in list of photos
|
||||||
|
window: window of time to search for nearest neighbor
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
nearest neighbor PhotoInfo object or None if no neighbor found
|
||||||
|
"""
|
||||||
|
idx_back = None
|
||||||
|
idx_forward = None
|
||||||
|
if idx > 0:
|
||||||
|
# search backwards in time
|
||||||
|
for i in range(idx - 1, -1, -1):
|
||||||
|
if (
|
||||||
|
photos[idx].date - photos[i].date <= window
|
||||||
|
and None not in photos[i].location
|
||||||
|
):
|
||||||
|
idx_back = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if idx < len(photos) - 1:
|
||||||
|
# search forwards in time
|
||||||
|
for i in range(idx + 1, len(photos)):
|
||||||
|
if (
|
||||||
|
photos[i].date - photos[idx].date <= window
|
||||||
|
and None not in photos[i].location
|
||||||
|
):
|
||||||
|
idx_forward = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if idx_back is not None and idx_forward is not None:
|
||||||
|
# found location in both directions
|
||||||
|
# use location closest in time
|
||||||
|
if (
|
||||||
|
photos[idx].date - photos[idx_back].date
|
||||||
|
< photos[idx_forward].date - photos[idx].date
|
||||||
|
):
|
||||||
|
return photos[idx_back]
|
||||||
|
else:
|
||||||
|
return photos[idx_forward]
|
||||||
|
elif idx_back is not None:
|
||||||
|
return photos[idx_back]
|
||||||
|
elif idx_forward is not None:
|
||||||
|
return photos[idx_forward]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@click.command(name="add-locations")
|
||||||
|
@click.option(
|
||||||
|
"--window",
|
||||||
|
"-w",
|
||||||
|
type=TimeOffset(),
|
||||||
|
default="1 hr",
|
||||||
|
help="Window of time to search for nearest neighbor; "
|
||||||
|
"searches +/- window of time. Default is 1 hour. "
|
||||||
|
"Format is one of 'HH:MM:SS', 'D days', 'H hours' (or hr), 'M minutes' (or min), "
|
||||||
|
"'S seconds' (or sec), 'S' (where S is seconds).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--dry-run",
|
||||||
|
is_flag=True,
|
||||||
|
help="Don't actually add location, just print what would be done. "
|
||||||
|
"Most useful with --verbose.",
|
||||||
|
)
|
||||||
|
@click.option("--verbose", "-V", "verbose_", is_flag=True, help="Print verbose output.")
|
||||||
|
@click.option(
|
||||||
|
"--timestamp", "-T", is_flag=True, help="Add time stamp to verbose output."
|
||||||
|
)
|
||||||
|
@QUERY_OPTIONS
|
||||||
|
@THEME_OPTION
|
||||||
|
@click.pass_obj
|
||||||
|
@click.pass_context
|
||||||
|
def add_locations(ctx, cli_ob, window, dry_run, verbose_, timestamp, theme, **kwargs):
|
||||||
|
"""Add missing location data to photos in Photos.app using nearest neighbor.
|
||||||
|
|
||||||
|
This command will search for photos that are missing location data and look
|
||||||
|
for the nearest neighbor photo within a given window of time that contains
|
||||||
|
location information. If a photo is found within the window of time, the
|
||||||
|
location of the nearest neighbor will be used to update the location of the
|
||||||
|
photo.
|
||||||
|
|
||||||
|
For example, if you took pictures with your iPhone and also with a camera that
|
||||||
|
doesn't have location information, you can use this command to add location
|
||||||
|
information to the photos taken with the camera from those taken with the
|
||||||
|
iPhone.
|
||||||
|
|
||||||
|
If you have many photos with missing location information but no nearest neighbor
|
||||||
|
within the window of time, you could add location information to some photos manually
|
||||||
|
then run this command again to add location information to the remaining photos.
|
||||||
|
|
||||||
|
You can specify a subset of photos to update using the query options. For example,
|
||||||
|
`--selected` to update only the selected photos, `--added-after 2020-01-01` to update
|
||||||
|
only photos added after Jan 1, 2020, etc.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
Add location data to all photos with missing location data within a ±2 hour window:
|
||||||
|
|
||||||
|
`osxphotos add-locations --window "2 hr" --verbose`
|
||||||
|
|
||||||
|
The add-locations command assumes that photos already have the correct date and time.
|
||||||
|
If you have photos that are missing both location data and date/time information,
|
||||||
|
you can use `osxphotos timewarp` to add date/time information to the photos and then
|
||||||
|
use `osxphotos add-locations` to add location information.
|
||||||
|
See `osxphotos help timewarp` for more information.
|
||||||
|
"""
|
||||||
|
color_theme = get_theme(theme)
|
||||||
|
verbose = verbose_print(
|
||||||
|
verbose_, timestamp, rich=True, theme=color_theme, highlight=False
|
||||||
|
)
|
||||||
|
# set console for rich_echo to be same as for verbose_
|
||||||
|
set_rich_console(get_verbose_console())
|
||||||
|
set_rich_theme(color_theme)
|
||||||
|
set_rich_timestamp(timestamp)
|
||||||
|
|
||||||
|
verbose("Searching for photos with missing location data...")
|
||||||
|
|
||||||
|
# load photos database
|
||||||
|
photosdb = osxphotos.PhotosDB(verbose=verbose)
|
||||||
|
query_options = query_options_from_kwargs(**kwargs)
|
||||||
|
photos = photosdb.query(query_options)
|
||||||
|
|
||||||
|
# sort photos by date
|
||||||
|
photos = sorted(photos, key=lambda p: p.date)
|
||||||
|
|
||||||
|
num_photos = len(photos)
|
||||||
|
missing_location = 0
|
||||||
|
found_location = 0
|
||||||
|
verbose(f"Processing {len(photos)} photos, window = ±{window}...")
|
||||||
|
with rich_progress(console=get_verbose_console(), mock=verbose_) as progress:
|
||||||
|
task = progress.add_task(
|
||||||
|
f"Processing [num]{num_photos}[/] {pluralize(len(photos), 'photo', 'photos')}, window = ±{window}",
|
||||||
|
total=num_photos,
|
||||||
|
)
|
||||||
|
for idx, photo in enumerate(photos):
|
||||||
|
if None in photo.location:
|
||||||
|
missing_location += 1
|
||||||
|
verbose(
|
||||||
|
f"Processing [filename]{photo.original_filename}[/] ([uuid]{photo.uuid}[/])"
|
||||||
|
)
|
||||||
|
if neighbor := get_location(photos, idx, window):
|
||||||
|
verbose(
|
||||||
|
f"Adding location {neighbor.location} to [filename]{photo.original_filename}[/] ([uuid]{photo.uuid}[/])"
|
||||||
|
f" from [filename]{neighbor.original_filename}[/] ([uuid]{neighbor.uuid}[/])"
|
||||||
|
)
|
||||||
|
found_location += 1
|
||||||
|
if not dry_run:
|
||||||
|
photoscript.Photo(photo.uuid).location = neighbor.location
|
||||||
|
else:
|
||||||
|
verbose(
|
||||||
|
f"No location found for [filename]{photo.original_filename}[/] ([uuid]{photo.uuid}[/])"
|
||||||
|
)
|
||||||
|
progress.advance(task)
|
||||||
|
rich_click_echo(
|
||||||
|
f"Done. Processed: [num]{num_photos}[/] photos, "
|
||||||
|
f"missing location: [num]{missing_location}[/], "
|
||||||
|
f"found location: [num]{found_location}[/] "
|
||||||
|
)
|
||||||
@@ -11,6 +11,7 @@ from osxphotos._constants import PROFILE_SORT_KEYS
|
|||||||
from osxphotos._version import __version__
|
from osxphotos._version import __version__
|
||||||
|
|
||||||
from .about import about
|
from .about import about
|
||||||
|
from .add_locations import add_locations
|
||||||
from .albums import albums
|
from .albums import albums
|
||||||
from .common import DB_OPTION, JSON_OPTION, OSXPHOTOS_HIDDEN
|
from .common import DB_OPTION, JSON_OPTION, OSXPHOTOS_HIDDEN
|
||||||
from .debug_dump import debug_dump
|
from .debug_dump import debug_dump
|
||||||
@@ -105,6 +106,7 @@ def cli_main(ctx, db, json_, profile, profile_sort, **kwargs):
|
|||||||
# install CLI commands
|
# install CLI commands
|
||||||
for command in [
|
for command in [
|
||||||
about,
|
about,
|
||||||
|
add_locations,
|
||||||
albums,
|
albums,
|
||||||
debug_dump,
|
debug_dump,
|
||||||
diff,
|
diff,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
""" pytest test configuration """
|
""" pytest test configuration """
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
import photoscript
|
import photoscript
|
||||||
import pytest
|
import pytest
|
||||||
@@ -21,6 +23,9 @@ TEST_IMPORT = False
|
|||||||
# run sync tests (configured with --test-sync)
|
# run sync tests (configured with --test-sync)
|
||||||
TEST_SYNC = False
|
TEST_SYNC = False
|
||||||
|
|
||||||
|
# run add-locations tests (configured with --test-add-locations)
|
||||||
|
TEST_ADD_LOCATIONS = False
|
||||||
|
|
||||||
# don't clean up crash logs (configured with --no-cleanup)
|
# don't clean up crash logs (configured with --no-cleanup)
|
||||||
NO_CLEANUP = False
|
NO_CLEANUP = False
|
||||||
|
|
||||||
@@ -51,11 +56,13 @@ if OS_VER == "15":
|
|||||||
TEST_LIBRARY_IMPORT = TEST_LIBRARY
|
TEST_LIBRARY_IMPORT = TEST_LIBRARY
|
||||||
TEST_LIBRARY_SYNC = TEST_LIBRARY
|
TEST_LIBRARY_SYNC = TEST_LIBRARY
|
||||||
from tests.config_timewarp_catalina import TEST_LIBRARY_TIMEWARP
|
from tests.config_timewarp_catalina import TEST_LIBRARY_TIMEWARP
|
||||||
|
|
||||||
|
TEST_LIBRARY_ADD_LOCATIONS = None
|
||||||
else:
|
else:
|
||||||
TEST_LIBRARY = None
|
TEST_LIBRARY = None
|
||||||
TEST_LIBRARY_TIMEWARP = None
|
TEST_LIBRARY_TIMEWARP = None
|
||||||
TEST_LIBRARY_SYNC = None
|
TEST_LIBRARY_SYNC = None
|
||||||
# pytest.exit("This test suite currently only runs on MacOS Catalina ")
|
TEST_LIBRARY_ADD_LOCATIONS = "tests/Test-13.0.0.photoslibrary"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
@@ -79,6 +86,13 @@ def setup_photos_sync():
|
|||||||
copy_photos_library(TEST_LIBRARY_SYNC, delay=10)
|
copy_photos_library(TEST_LIBRARY_SYNC, delay=10)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def setup_photos_add_locations():
|
||||||
|
if not TEST_ADD_LOCATIONS:
|
||||||
|
return
|
||||||
|
copy_photos_library(TEST_LIBRARY_ADD_LOCATIONS, delay=10)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def reset_singletons():
|
def reset_singletons():
|
||||||
"""Need to clean up any ExifTool singletons between tests"""
|
"""Need to clean up any ExifTool singletons between tests"""
|
||||||
@@ -107,6 +121,12 @@ def pytest_addoption(parser):
|
|||||||
default=False,
|
default=False,
|
||||||
help="run `osxphotos sync` tests",
|
help="run `osxphotos sync` tests",
|
||||||
)
|
)
|
||||||
|
parser.addoption(
|
||||||
|
"--test-add-locations",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="run `osxphotos add-locations` tests",
|
||||||
|
)
|
||||||
parser.addoption(
|
parser.addoption(
|
||||||
"--no-cleanup",
|
"--no-cleanup",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -144,6 +164,10 @@ def pytest_configure(config):
|
|||||||
config.addinivalue_line(
|
config.addinivalue_line(
|
||||||
"markers", "test_sync: mark test as requiring --test-sync to run"
|
"markers", "test_sync: mark test as requiring --test-sync to run"
|
||||||
)
|
)
|
||||||
|
config.addinivalue_line(
|
||||||
|
"markers",
|
||||||
|
"test_add_locations: mark test as requiring --test-add-locations to run",
|
||||||
|
)
|
||||||
|
|
||||||
# this is hacky but I can't figure out how to check config options in other fixtures
|
# this is hacky but I can't figure out how to check config options in other fixtures
|
||||||
if config.getoption("--timewarp"):
|
if config.getoption("--timewarp"):
|
||||||
@@ -158,6 +182,10 @@ def pytest_configure(config):
|
|||||||
global TEST_SYNC
|
global TEST_SYNC
|
||||||
TEST_SYNC = True
|
TEST_SYNC = True
|
||||||
|
|
||||||
|
if config.getoption("--test-add-locations"):
|
||||||
|
global TEST_ADD_LOCATIONS
|
||||||
|
TEST_ADD_LOCATIONS = True
|
||||||
|
|
||||||
if config.getoption("--no-cleanup"):
|
if config.getoption("--no-cleanup"):
|
||||||
global NO_CLEANUP
|
global NO_CLEANUP
|
||||||
NO_CLEANUP = True
|
NO_CLEANUP = True
|
||||||
@@ -196,17 +224,25 @@ def pytest_collection_modifyitems(config, items):
|
|||||||
if "test_sync" in item.keywords:
|
if "test_sync" in item.keywords:
|
||||||
item.add_marker(skip_test_sync)
|
item.add_marker(skip_test_sync)
|
||||||
|
|
||||||
|
if not (
|
||||||
def copy_photos_library(photos_library, delay=0):
|
config.getoption("--test-add-locations")
|
||||||
"""copy the test library and open Photos, returns path to copied library"""
|
and TEST_LIBRARY_ADD_LOCATIONS is not None
|
||||||
script = AppleScript(
|
):
|
||||||
"""
|
skip_test_sync = pytest.mark.skip(
|
||||||
tell application "Photos"
|
reason="need --test-add-locations option and MacOS Ventura to run"
|
||||||
quit
|
|
||||||
end tell
|
|
||||||
"""
|
|
||||||
)
|
)
|
||||||
script.run()
|
for item in items:
|
||||||
|
if "test_add_locations" in item.keywords:
|
||||||
|
item.add_marker(skip_test_sync)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_photos_library(photos_library, delay=0, open=True):
|
||||||
|
"""copy the test library and open Photos, returns path to copied library"""
|
||||||
|
|
||||||
|
# quit Photos if it's running
|
||||||
|
photoslib = photoscript.PhotosLibrary()
|
||||||
|
photoslib.quit()
|
||||||
|
|
||||||
src = pathlib.Path(os.getcwd()) / photos_library
|
src = pathlib.Path(os.getcwd()) / photos_library
|
||||||
picture_folder = (
|
picture_folder = (
|
||||||
pathlib.Path(os.environ["PHOTOSCRIPT_PICTURES_FOLDER"])
|
pathlib.Path(os.environ["PHOTOSCRIPT_PICTURES_FOLDER"])
|
||||||
@@ -216,27 +252,34 @@ def copy_photos_library(photos_library, delay=0):
|
|||||||
picture_folder = picture_folder.expanduser()
|
picture_folder = picture_folder.expanduser()
|
||||||
if not picture_folder.is_dir():
|
if not picture_folder.is_dir():
|
||||||
pytest.exit(f"Invalid picture folder: '{picture_folder}'")
|
pytest.exit(f"Invalid picture folder: '{picture_folder}'")
|
||||||
dest = picture_folder / photos_library
|
dest = picture_folder / pathlib.Path(photos_library).name
|
||||||
ditto(src, dest)
|
|
||||||
script = AppleScript(
|
# copy src directory to dest directory, removing it if it already exists
|
||||||
f"""
|
shutil.rmtree(str(dest), ignore_errors=True)
|
||||||
set tries to 0
|
|
||||||
repeat while tries < 5
|
print(f"copying {src} to {picture_folder} ...")
|
||||||
try
|
copyFolder = AppleScript(
|
||||||
tell application "Photos"
|
"""
|
||||||
activate
|
on copyFolder(sourceFolder, destinationFolder)
|
||||||
delay 3
|
-- sourceFolder and destinationFolder are strings of POSIX paths
|
||||||
open POSIX file "{dest}"
|
set sourceFolder to POSIX file sourceFolder
|
||||||
delay {delay}
|
set destinationFolder to POSIX file destinationFolder
|
||||||
|
tell application "Finder"
|
||||||
|
duplicate sourceFolder to destinationFolder
|
||||||
end tell
|
end tell
|
||||||
set tries to 5
|
end copyFolder
|
||||||
on error
|
|
||||||
set tries to tries + 1
|
|
||||||
end try
|
|
||||||
end repeat
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
script.run()
|
copyFolder.call("copyFolder", str(src), str(picture_folder))
|
||||||
|
|
||||||
|
# open Photos
|
||||||
|
if open:
|
||||||
|
# sometimes doesn't open the first time
|
||||||
|
time.sleep(delay)
|
||||||
|
photoslib.open(str(dest))
|
||||||
|
time.sleep(delay)
|
||||||
|
photoslib.open(str(dest))
|
||||||
|
|
||||||
return dest
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
52
tests/test_cli_add_locations.py
Normal file
52
tests/test_cli_add_locations.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Test osxphotos add-locations command"""
|
||||||
|
|
||||||
|
import photoscript
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from osxphotos.cli.add_locations import add_locations
|
||||||
|
|
||||||
|
UUID_TEST_PHOTO_1 = "F12384F6-CD17-4151-ACBA-AE0E3688539E" # Pumkins1.jpg
|
||||||
|
UUID_TEST_PHOTO_LOCATION = "D79B8D77-BFFC-460B-9312-034F2877D35B" # Pumkins2.jpg
|
||||||
|
TEST_LOCATION = (41.26067, -95.94056) # Omaha, NE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.test_add_locations
|
||||||
|
def test_add_locations():
|
||||||
|
"""Test add-locations command"""
|
||||||
|
|
||||||
|
with CliRunner().isolated_filesystem():
|
||||||
|
# need to clear location data from test photo
|
||||||
|
test_photo = photoscript.Photo(UUID_TEST_PHOTO_1)
|
||||||
|
test_photo.location = None
|
||||||
|
source_photo = photoscript.Photo(UUID_TEST_PHOTO_LOCATION)
|
||||||
|
source_photo.location = TEST_LOCATION
|
||||||
|
|
||||||
|
# now run add-locations
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
add_locations,
|
||||||
|
[
|
||||||
|
"--window",
|
||||||
|
"2 hours",
|
||||||
|
"--dry-run",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
# should find 3 matching locations: Pumkins1.jpg, Pumpkins3.jpg, Pumpkins4.jpg
|
||||||
|
assert "found location: 3" in result.output
|
||||||
|
|
||||||
|
# verify location not really added
|
||||||
|
assert test_photo.location == (None, None)
|
||||||
|
|
||||||
|
# run again without dry-run
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
add_locations,
|
||||||
|
[
|
||||||
|
"--window",
|
||||||
|
"2 hours",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
# should find 3 matching locations: Pumkins1.jpg, Pumpkins3.jpg, Pumpkins4.jpg
|
||||||
|
assert "found location: 3" in result.output
|
||||||
|
assert test_photo.location == TEST_LOCATION
|
||||||
Reference in New Issue
Block a user