diff --git a/examples/find_bad_extensions.py b/examples/find_bad_extensions.py new file mode 100644 index 00000000..fccb15f7 --- /dev/null +++ b/examples/find_bad_extensions.py @@ -0,0 +1,150 @@ +"""Scan Photos library to find photos with bad (incorrect) file extensions. + +This can be run with osxphotos via: osxphotos run find_bad_extensions.py + +For help, run: osxphotos run find_bad_extensions.py --help +""" + +from __future__ import annotations + +import csv +import json +import os +import pathlib +import sys + +import click +from rich import print + +from osxphotos import PhotoInfo, PhotosDB +from osxphotos.cli.common import get_data_dir +from osxphotos.exiftool import ExifTool, get_exiftool_path +from osxphotos.sqlitekvstore import SQLiteKVStore + + +def check_extension(filepath: str) -> tuple[bool, str, str]: + """Check if file extension is correct for image file using exiftool + + Args: + filepath: path to file to check + + Returns: tuple of (bool, str, str) where bool is True if extension is correct, False if not + and str, str is the current extension, correct extension or current extension if correct + """ + filepath = pathlib.Path(filepath) + current_ext = filepath.suffix.lower() + + current_ext = current_ext[1:] if current_ext else "" # remove leading dot + exiftool = ExifTool(filepath) + correct_ext = exiftool.asdict().get("File:FileTypeExtension").lower() + if current_ext != correct_ext: + # there are some extensions that have more than one valid extension + # there are likely more but these are the ones I've seen so far + is_correct = ( + current_ext in ("jpg", "jpeg") and correct_ext in ("jpg", "jpeg") + ) or (current_ext in ("tif", "tiff") and correct_ext in ("tif", "tiff")) + else: + is_correct = True + return is_correct, current_ext, correct_ext + + +def check_photo( + photo: PhotoInfo, recheck: bool, version: str, kvstore: SQLiteKVStore +) -> None: + """Check PhotoInfo for correct extension + + Args: + photo: PhotoInfo instance + recheck: if True, recheck even if previously checked + version: "original" or "edited" + kvstore: SQLiteKVStore instance to store results + """ + photo_path = photo.path if version == "original" else photo.path_edited + if photo_path is None: + print( + f":warning-emoji: [yellow]No {version} path for photo: {photo.original_filename} ({photo.uuid})", + file=sys.stderr, + ) + return + if recheck or f"{photo.uuid}:{version}" not in kvstore: + is_correct, current_ext, correct_ext = check_extension(photo_path) + if not is_correct: + print( + f"{photo.original_filename} ({version}) has incorrect extension: [red]{current_ext}[/] should be [green]{correct_ext}[/]", + file=sys.stderr, + ) + # output results as CSV to stdout + csv.writer(sys.stdout).writerow( + [ + photo.uuid, + photo.original_filename, + version, + current_ext, + correct_ext, + photo_path, + ] + ) + kvstore[f"{photo.uuid}:{version}"] = (is_correct, current_ext, correct_ext) + + +@click.command() +@click.option( + "--library", + default=None, + type=click.Path(exists=True, file_okay=True, dir_okay=True), + help="Path to Photos library to use. Default is to use default Photos library.", +) +@click.option( + "--recheck", + is_flag=True, + help="Recheck all files even if previously checked and cached.", +) +@click.option( + "--edited", + is_flag=True, + help="Check edited versions of photos in addition to originals.", +) +def main(library: str, recheck: bool, edited: bool): + """Scan Photos library to find photos with bad (incorrect) file extensions. + + This can be run with osxphotos via: `osxphotos run find_bad_extensions.py` + + Both STDOUT and STDERR are used to output results. + + STDOUT is used to output a CSV file with the following columns: + + uuid, original_filename, version, current_extension, correct_extension, path + + Thus, to save the results to a file, run: + + osxphotos run find_bad_extensions.py > results.csv + """ + + # exiftool required to run + try: + get_exiftool_path() + except FileNotFoundError as e: + print( + ":cross_mark-emoji: [red]Could not find exiftool. Please download and install" + " from https://exiftool.org/", + file=sys.stderr, + ) + raise click.Abort() from e + + # path to the cache database to store results of extension check + cache_db_path = os.path.join(get_data_dir(), "bad_extensions.db") + kvstore = SQLiteKVStore( + cache_db_path, wal=True, serialize=json.dumps, deserialize=json.loads + ) + click.echo(f"Using cache database: [blue]{cache_db_path}", err=True) + + # load the Photos database and check each photo + photosdb = PhotosDB(dbfile=library) + for photo in photosdb.photos(): + check_photo(photo, recheck, "original", kvstore) + if edited and photo.hasadjustments: + check_photo(photo, recheck, "edited", kvstore) + + +if __name__ == "__main__": + main() diff --git a/examples/fix_export_extension.py b/examples/fix_export_extension.py new file mode 100644 index 00000000..0f4ad439 --- /dev/null +++ b/examples/fix_export_extension.py @@ -0,0 +1,44 @@ +""" Example function for use with osxphotos export --post-function option """ + +import pathlib +from typing import Callable + +from osxphotos import ExportResults, PhotoInfo +from osxphotos.exiftool import ExifTool + + +def fix_extension( + photo: PhotoInfo, results: ExportResults, verbose: Callable, **kwargs +): + """Call this with osxphotos export /path/to/export --post-function fix_export_extension.py::fix_extension + This will get called immediately after the photo has been exported + + See full example here: https://github.com/RhetTbull/osxphotos/blob/master/examples/post_function.py + + Args: + photo: PhotoInfo instance for the photo that's just been exported + results: ExportResults instance with information about the files associated with the exported photo + verbose: A function to print verbose output if --verbose is set; if --verbose is not set, acts as a no-op (nothing gets printed) + **kwargs: reserved for future use; recommend you include **kwargs so your function still works if additional arguments are added in future versions + + Notes: + Use verbose(str) instead of print if you want your function to conditionally output text depending on --verbose flag + Any string printed with verbose that contains "warning" or "error" (case-insensitive) will be printed with the appropriate warning or error color + Will not be called if --dry-run flag is enabled + Will be called immediately after export and before any --post-command commands are executed + """ + + for filepath in results.exported: + filepath = pathlib.Path(filepath) + ext = filepath.suffix.lower() + if not ext: + continue + ext = ext[1:] # remove leading dot + exiftool = ExifTool(filepath) + actual_ext = exiftool.asdict().get("File:FileTypeExtension").lower() + if ext != actual_ext and (ext not in ("jpg", "jpeg") or actual_ext != "jpg"): + # WARNING: Does not check for name collisions; left as an exercise for the reader + verbose(f"Fixing extension for {filepath} from {ext} to {actual_ext}") + new_filepath = filepath.with_suffix(f".{actual_ext}") + verbose(f"Renaming {filepath} to {new_filepath}") + filepath.rename(new_filepath)