Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Feature push exif 160 #1190

Merged
merged 38 commits into from
Sep 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
5a01cca
All tests pass
RhetTbull Aug 26, 2023
d7480a3
Added ExifOptions
RhetTbull Aug 26, 2023
eb9a9fe
Removed test that causes Photos crash on Ventura
RhetTbull Aug 26, 2023
d9caee5
Added docs for ExifWriter
RhetTbull Aug 26, 2023
7d46acf
Added SidecarWriter
RhetTbull Aug 26, 2023
1400a11
Fixed sidecar test generator
RhetTbull Aug 26, 2023
f942047
Added SidecarWriter docs
RhetTbull Aug 26, 2023
4280122
Fixed ruff error
RhetTbull Aug 26, 2023
76aafb0
Fixed formatting
RhetTbull Aug 26, 2023
9cc19ce
Initial tests for ExifWriter
RhetTbull Aug 27, 2023
0304230
Fixed broken test
RhetTbull Aug 27, 2023
65abcd8
Tests passing for ExifWriter
RhetTbull Aug 28, 2023
e52dbf7
Fixed broken test
RhetTbull Aug 30, 2023
0cee3af
Refactored sidecar code, added sidecar tests
RhetTbull Aug 31, 2023
6486285
Fixed test for CI
RhetTbull Sep 1, 2023
1a65734
Fixed example [skip ci]
RhetTbull Sep 1, 2023
a32ffec
Initial push-exif CLI
RhetTbull Sep 1, 2023
99ba4af
removed unneeded import
RhetTbull Sep 1, 2023
59303d9
Fixed --verbose/--theme, #1186
RhetTbull Sep 1, 2023
d73abdf
Added results to push-exif
RhetTbull Sep 2, 2023
e542adb
Added CSV report to push-exif
RhetTbull Sep 2, 2023
0eea8a9
Added JSON report to push-exif
RhetTbull Sep 2, 2023
e5def63
Added SQLite report to push-exif
RhetTbull Sep 2, 2023
11b6c26
Update tests.yml
RhetTbull Sep 2, 2023
0d87446
Updated ruff line lengths
RhetTbull Sep 2, 2023
7af05d7
Updated SQLite report for push-exif to include command line
RhetTbull Sep 2, 2023
a37c099
Update tests.yml
RhetTbull Sep 2, 2023
2d09c40
Added import_shared.py example
RhetTbull Aug 30, 2023
d7a61f2
Updated timeout
RhetTbull Aug 31, 2023
0373ca9
Added ntfy notifications (#1185)
RhetTbull Sep 1, 2023
30cc016
Removed success check
RhetTbull Sep 1, 2023
d5b4137
Fix for {photo.location} #1187, added photo.latitude, photo.longitude…
RhetTbull Sep 2, 2023
7973bfa
Added basic tests for push-exif
RhetTbull Sep 2, 2023
87e6b6a
Added merge tests
RhetTbull Sep 2, 2023
3be9d04
added skip if exiftool not found
RhetTbull Sep 2, 2023
df8e140
Added tests for all options
RhetTbull Sep 2, 2023
1c912f8
Added tests for all metadata options
RhetTbull Sep 3, 2023
b20ab9d
Added --compare option
RhetTbull Sep 3, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ jobs:
steps:
- uses: actions/checkout@v3
- run: pip install --user ruff # Lint Python twice: 1. Whole repo, 2. Exclude tests/
- run: ruff --format=github --line-length=7228 --target-version=py39
- run: ruff --format=github --line-length=320 --target-version=py39
--ignore=E402,E712,E721,E721,E722,E741,F401,F403,F405,F541,F601,F811,F822,F841 .
- run: ruff --format=github --line-length=366 --target-version=py39
- run: ruff --format=github --line-length=320 --target-version=py39
--exclude=tests/ --ignore=E402,E721,E722,E741,F401,F403,F405,F541,F822,F841 .
build:
runs-on: ${{ matrix.os }}
Expand Down Expand Up @@ -42,3 +42,10 @@ jobs:
- name: Test with pytest
run: |
python -m pytest -v tests/
- name: Send failure notification
if: ${{ failure() }}
run: |
curl \
-H "Click: https://github.com/RhetTbull/osxphotos/actions/" \
-d "osxphotos test failed" \
ntfy.sh/rhettbull_github_actions
242 changes: 175 additions & 67 deletions API_README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions dev_requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pyinstaller==5.6.2
pytest-cov==4.0.0
pytest-mock
pytest==7.4.0
ruff==0.0.286
Sphinx
sphinx_click
sphinx_rtd_theme
Expand Down
285 changes: 285 additions & 0 deletions examples/import_shared.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
"""Import photos from shared Photos albums into Photos library as regular (non-shared) photos.

Required questionary which is not a regular osxphotos dependency.
To use:

install osxphotos (see: https://github.com/RhetTbull/osxphotos#installation)
run `osxphotos install questionary`
run `osxphotos run import_shared.py`
"""

from __future__ import annotations

import tempfile
from typing import Callable

import click
import photoscript
import questionary
from rich.progress import Progress

import osxphotos
from osxphotos import AlbumInfo, PhotoInfo
from osxphotos.cli import echo, echo_error
from osxphotos.cli.verbose import verbose_print
from osxphotos.photosalbum import PhotosAlbumPhotoScript
from osxphotos.queryoptions import QueryOptions
from osxphotos.utils import pluralize


def pluralize_photos(photos: list[PhotoInfo]) -> str:
"""Return str with number of photos and pluralized 'photo' or 'photos'"""
length = len(photos)
return f"{length} {pluralize(length, 'photo', 'photos')}"


@click.command(name="import-shared")
def main():
"""Import photos from classic shared albums into Photos library.

This command will import photos from shared albums into your Photos library as regular (non-shared) photos.

You will be prompted to select which shared albums to import,
which users to ignore (for example, yourself to avoid duplicates),
album structure, and metadata to import.

The command will export photos one at a time and import them to Photos then apply
the albums and metadata. Thus your "Imports" folder in Photos will show an import
for each photo imported instead of one import session for all photos.
"""

# print documentation
echo(main.__doc__)
if not questionary.confirm("Continue?").ask():
raise click.Abort()

# load the photos database
photos_to_import = get_photos_to_import()
echo(
f"Found {len(photos_to_import)} {pluralize_photos(photos_to_import)} to import."
)

# which albums to import into?
same_albums = questionary.confirm(
"Import into albums with same names as shared albums?"
).ask()
if questionary.confirm(
"Would you like to specify an album name to import into?",
default=False,
).ask():
album_name = questionary.text("Enter album name:").ask()
else:
album_name = None

# metadata
person_keywords = questionary.confirm(
"Add keywords for owners of shared photos?"
).ask()
comments = questionary.confirm(
"Add comments for shared photos to description of imported photo?"
).ask()
favorite = questionary.confirm(
"Set favorite flag on imported photos with likes?"
).ask()

if questionary.confirm("Are you sure you want to import?").ask():
import_shared_photos(
photos_to_import,
same_albums,
album_name,
person_keywords,
comments,
favorite,
)


def get_photos_to_import() -> list[PhotoInfo]:
"""Get list of photos to import from shared albums"""
echo("Loading photos database (this could take a little while)...")
photosdb = osxphotos.PhotosDB()
photos = photosdb.query(QueryOptions(shared=True))
shared_albums = photosdb.album_info_shared
echo(
f"Found {pluralize_photos(photos)} "
f"in {len(shared_albums)} shared {pluralize(len(shared_albums), 'album', 'albums')}"
)

photos = ask_shared_albums(photos, shared_albums)
echo(f"Filtered to {pluralize_photos(photos)}")
if not photos:
return []
photos = ask_shared_owners(photos)
echo(f"Filtered to {pluralize_photos(photos)}")
return photos or []


def ask_shared_albums(photos: list[PhotoInfo], shared_albums: list[AlbumInfo]):
"""Ask user about which shared albums to import"""
shared_album_info = get_shared_album_info(shared_albums)
if questionary.confirm("Include all shared albums?").ask():
return photos
if include := questionary.checkbox(
"Select shared album(s) to *include* in import or press Enter to import all",
choices=list(shared_album_info.keys()),
).ask():
shared_albums_include = [shared_album_info[x] for x in include]
else:
# user hit Enter without selection, return all
return photos

album_photos = []
for album in shared_albums_include:
album_photos.extend(p for p in photos if p in album.photos)
return album_photos


def get_shared_owners(photos: list[PhotoInfo]) -> dict[str, int]:
"""Return dict of owners of shared photos"""
owners = {}
for photo in photos:
try:
owners[photo.owner] += 1
except KeyError:
owners[photo.owner] = 1

return {
f"{k} ({v} {pluralize(v, 'photo', 'photos')})": k
for k, v in sorted(owners.items(), key=lambda x: x[1], reverse=True)
}


def ask_shared_owners(photos: list[PhotoInfo]) -> list[PhotoInfo]:
"""Ask user which owners to include"""
shared_owner_info = get_shared_owners(photos)
exclude = questionary.checkbox(
"Select shared photo owner(s) to *exclude* from import or press Enter to import all",
choices=list(shared_owner_info.keys()),
).ask()
exclude_owners = [shared_owner_info[x] for x in exclude]
return get_shared_photos(photos, exclude_owners)


def get_shared_album_info(shared_albums: list[AlbumInfo]) -> dict[str, int]:
"""Return dict with info about shared albums"""
albums = {}
for album in shared_albums:
photo_owners = {photo.owner for photo in album.photos}
album_key = f"{album.title} ({pluralize_photos(album.photos)} by {', '.join(photo_owners)}"
albums[album_key] = album
return albums


def get_shared_photos(photos: list[PhotoInfo], exclude: list[str]) -> list[PhotoInfo]:
"""Return list of photos to import"""
return [photo for photo in photos if photo.owner not in exclude]


def import_shared_photos(
photos: list[PhotoInfo],
same_albums: bool,
album_name: str,
person_keywords: bool,
comments: bool,
favorite: bool,
):
"""Import shared photos into Photos library"""
echo(
f"Importing {pluralize_photos(photos)}. Will{'' if same_albums else ' not'} import into albums with same names as shared albums."
)
if album_name:
echo(f"Will import into album '{album_name}'")

imported_count = 0
with Progress() as progress:
task = progress.add_task("Importing", total=len(photos))
for photo in photos:
progress.update(
task,
advance=1,
description=f"{photo.original_filename}, [italic]{photo.albums[0]}[/i], by {photo.owner}",
)
imported_count += export_import_photo(
photo, same_albums, album_name, person_keywords, comments, favorite
)
echo(
f"Done. Imported {imported_count} {pluralize(imported_count, 'photo', 'photos')}."
)


def export_import_photo(
photo: PhotoInfo,
same_albums: bool,
album_name: str,
person_keywords: bool,
comments: bool,
favorite: bool,
):
"""Export a shared photo and import as a regular (not shared) photo"""
try:
with tempfile.TemporaryDirectory() as tmpdir:
exported = photo.export(
tmpdir,
photo.original_filename,
use_photos_export=photo.ismissing,
timeout=300,
)
if not exported:
echo_error(f"Error exporting {photo.original_filename}")
return 0
import_count = import_photos(
photo,
exported,
same_albums,
album_name,
person_keywords,
comments,
favorite,
)
return import_count
except KeyboardInterrupt as e:
raise KeyboardInterrupt from e
except Exception as e:
echo_error(f"Error importing {photo.original_filename}: {e}")
return 0


def import_photos(
photo: PhotoInfo,
exported: list[str],
same_albums: bool,
album_name: str,
person_keywords: bool,
comments: bool,
favorite: bool,
):
"""Import exported photos into Photos library"""
photoslib = photoscript.PhotosLibrary()
imported_photos = photoslib.import_photos(exported)

# albums
album_names = []
if same_albums:
album_names.extend(photo.albums)
if album_name:
album_names.append(album_name)
for album_name in album_names:
album = PhotosAlbumPhotoScript(album_name)
album.add_list(imported_photos)

# metadata
for imported_photo in imported_photos:
if person_keywords:
imported_photo.keywords = photo.persons
if comments:
description = ", ".join(
f"{comment.text} ({comment.user})" for comment in photo.comments
)
imported_photo.description = description
if favorite:
imported_photo.favorite = photo.likes

return len(imported_photos)


if __name__ == "__main__":
main()
7 changes: 6 additions & 1 deletion osxphotos/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
from .debug import is_debug, set_debug
from .exifinfo import ExifInfo
from .exiftool import ExifTool
from .exifwriter import ExifWriter
from .export_db import ExportDB, ExportDBTemp
from .exportoptions import ExportOptions, ExportResults
from .fileutil import FileUtil, FileUtilNoOp
from .momentinfo import MomentInfo
from .personinfo import FaceInfo, PersonInfo
from .photoexporter import ExportOptions, ExportResults, PhotoExporter
from .photoexporter import PhotoExporter
from .photoinfo import PhotoInfo
from .photosdb import PhotosDB
from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo
Expand All @@ -25,6 +27,7 @@
from .queryoptions import QueryOptions
from .scoreinfo import ScoreInfo
from .searchinfo import SearchInfo
from .sidecars import SidecarWriter

if is_macos:
from .photosalbum import PhotosAlbum, PhotosAlbumPhotoScript
Expand All @@ -44,6 +47,7 @@
"CommentInfo",
"ExifInfo",
"ExifTool",
"ExifWriter",
"ExportDB",
"ExportDBTemp",
"ExportOptions",
Expand All @@ -68,6 +72,7 @@
"QueryOptions",
"ScoreInfo",
"SearchInfo",
"SidecarWriter",
"__version__",
"is_debug",
"logger",
Expand Down
2 changes: 2 additions & 0 deletions osxphotos/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
from .batch_edit import batch_edit
from .import_cli import import_cli
from .photo_inspect import photo_inspect
from .push_exif import push_exif
from .show_command import show
from .sync import sync
from .timewarp import timewarp
Expand Down Expand Up @@ -141,6 +142,7 @@
"batch_edit",
"import_cli",
"photo_inspect",
"push_exif",
"show",
"sync",
"timewarp",
Expand Down
2 changes: 2 additions & 0 deletions osxphotos/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from .batch_edit import batch_edit
from .import_cli import import_cli
from .photo_inspect import photo_inspect
from .push_exif import push_exif
from .show_command import show
from .sync import sync
from .timewarp import timewarp
Expand Down Expand Up @@ -145,6 +146,7 @@ def at_exit():
batch_edit,
import_cli,
photo_inspect,
push_exif,
show,
sync,
timewarp,
Expand Down
Loading