Created
November 7, 2025 18:32
-
-
Save oPromessa/2be1467b4ec2be62c2ebecbb165af0d4 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """Move all albums that have the same name as their parent folder and are | |
| alone in that folder one folder up. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import re | |
| import sys | |
| import click | |
| import photoscript | |
| # Set up logging | |
| logger = logging.getLogger("album-mover") | |
| @click.command() | |
| @click.option( | |
| "--album-filter", | |
| nargs=1, | |
| required=False, | |
| type=click.STRING, | |
| help="Specify optional regular expression filter for album names to be processed.", | |
| ) | |
| @click.option( | |
| "--interactive", | |
| "-i", | |
| is_flag=True, | |
| help="Run in interactive mode. Confirm with one of 'Yes/No/Yes to All/Cancel' " | |
| "to Album move operations (create folder operations are not interactive).", | |
| ) | |
| @click.option( | |
| "--verbose", "-v", count=True, help="Increase verbosity level." | |
| ) | |
| @click.option("--dry-run", is_flag=True, help="Perform a run with no changes made.") | |
| def album_mover(verbose, dry_run, album_filter, interactive): | |
| """Move all albums that have the same name as their parent folder and are | |
| alone in that folder one folder up. | |
| """ | |
| # Logging level set | |
| if verbose == 0: | |
| logging.basicConfig(level=logging.WARNING) | |
| elif verbose == 1: | |
| logging.basicConfig(level=logging.INFO) | |
| else: | |
| logging.basicConfig(level=logging.DEBUG) | |
| # compile regex filter if provided | |
| regex = None | |
| if album_filter: | |
| try: | |
| regex = re.compile(album_filter) | |
| except re.error as e: | |
| logger.error(f"album_filter regex invalid: {e}") | |
| sys.exit(1) | |
| lib = photoscript.PhotosLibrary() # connect to Photos | |
| lib.activate() | |
| logger.info(f"Using PhotosLibrary version: {lib.version}") | |
| # Get all folders (containers) and albums | |
| # Note: depending on PhotoScript API, you may need to adjust how to list folders/albums | |
| all_folders = lib.folders( | |
| top_level=False | |
| ) # assume .folders() returns list of Folder objects | |
| logger.debug(f"Found {len(all_folders)} folders") | |
| yes_to_all = False | |
| for folder in all_folders: | |
| # For each folder, get its sub-folders and albums | |
| sub_folders = folder.subfolders | |
| albums_in_folder = folder.albums | |
| logger.debug( | |
| f"Folder '{folder.name}' has {len(sub_folders)} subfolders and {len(albums_in_folder)} albums" | |
| ) | |
| # We are interested in the case: a folder which has no further subfolders, and exactly one album inside, | |
| # and that album has the **same name** as the folder. | |
| if len(sub_folders) == 0 and len(albums_in_folder) == 1: | |
| album = albums_in_folder[0] | |
| if album.name == folder.name: | |
| # If an album_filter is given, test the album name | |
| if regex and not regex.search(album.name): | |
| logger.info( | |
| f"Skipping album '{album.name}' because it doesn’t match filter '{regex.pattern}'" | |
| ) | |
| continue | |
| elif regex: | |
| logger.info( | |
| f"Processing album '{album.name}' because it matches filter '{regex.pattern}'" | |
| ) | |
| # Identify the target parent: the folder’s parent folder | |
| parent_folder = ( | |
| folder.parent | |
| ) # assume container() gives the parent folder or top-level | |
| if parent_folder is None or parent_folder == 0: | |
| logger.debug( | |
| f"Folder '{folder.name}' is already top-level (or container unknown); skipping" | |
| ) | |
| continue | |
| # The move action: move the album out of its folder, up one level, into parent_folder | |
| logger.info( | |
| f"Will move album '{album.name}' from folder '{folder.name}' → folder '{parent_folder.name}'" | |
| ) | |
| if dry_run: | |
| continue | |
| if interactive and not yes_to_all: | |
| print( | |
| f"Move album '{album.name}' from '{folder.name}' → '{parent_folder.name}' ? [Yes/No/Yes to All/Cancel] " | |
| ) | |
| while True: | |
| key = click.getchar().lower() | |
| if key in ["y", "n", "a", "c"]: | |
| break | |
| match key: | |
| case "y": | |
| pass | |
| case "a": | |
| yes_to_all = True | |
| case "n": | |
| logger.info( | |
| f"Skipping album '{album.name}' by user request" | |
| ) | |
| continue | |
| case "c": | |
| print("Cancelling...") | |
| return | |
| case _: | |
| logger.error("Invalid choice, please press Y, N, A, or C:") | |
| pass | |
| try: | |
| album.move( | |
| parent_folder | |
| ) # or album.move(parent_folder) depending API | |
| logger.info(f"Moved album '{album.name}' successfully") | |
| except Exception as e: | |
| logger.error(f"Error moving album '{album.name}': {e}") | |
| else: | |
| logger.debug( | |
| f"Album name '{album.name}' != folder name '{folder.name}' → skipping" | |
| ) | |
| else: | |
| logger.debug( | |
| f"Folder '{folder.name}' does not meet criteria (no subfolders & exactly one album) → skipping" | |
| ) | |
| if __name__ == "__main__": | |
| album_mover() # pylint: disable=E1120 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment