Skip to content

Instantly share code, notes, and snippets.

@oPromessa
Created November 7, 2025 18:32
Show Gist options
  • Select an option

  • Save oPromessa/2be1467b4ec2be62c2ebecbb165af0d4 to your computer and use it in GitHub Desktop.

Select an option

Save oPromessa/2be1467b4ec2be62c2ebecbb165af0d4 to your computer and use it in GitHub Desktop.
"""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