From 891ea9d3a202aa597d763414c7e1c0135733581f Mon Sep 17 00:00:00 2001 From: Salvoxia Date: Fri, 10 May 2024 19:01:39 +0200 Subject: [PATCH] Added support for album level ranges to support creating album names from only specific ranges of the folder structure --- immich_auto_album.py | 113 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 95 insertions(+), 18 deletions(-) diff --git a/immich_auto_album.py b/immich_auto_album.py index 2cd7450..c810cbf 100644 --- a/immich_auto_album.py +++ b/immich_auto_album.py @@ -4,8 +4,17 @@ import logging import sys import datetime +import array as arr from collections import defaultdict +# Trying to deal with python's isnumeric() function +# not recognizing negative numbers +def is_integer(str): + try: + int(str) + return True + except ValueError: + return False parser = argparse.ArgumentParser(description="Create Immich Albums from an external library path based on the top level folders", formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument("root_path", action='append', help="The external libarary's root path in Immich") @@ -13,7 +22,7 @@ parser.add_argument("api_key", help="The Immich API Key to use") parser.add_argument("-r", "--root-path", action="append", help="Additional external libarary root path in Immich; May be specified multiple times for multiple import paths or external libraries.") parser.add_argument("-u", "--unattended", action="store_true", help="Do not ask for user confirmation after identifying albums. Set this flag to run script as a cronjob.") -parser.add_argument("-a", "--album-levels", default=1, type=int, help="Number of sub-folders below the root path used for album name creation. Positive numbers start from top of the folder structure, negative numbers from the bottom. Cannot be 0.") +parser.add_argument("-a", "--album-levels", default="1", type=str, help="Number of sub-folders or range of sub-folder levels below the root path used for album name creation. Positive numbers start from top of the folder structure, negative numbers from the bottom. Cannot be 0. If a range should be set, the start level and end level must be separated by a comma like ','. If negative levels are used in a range, must be less than or equal to .") parser.add_argument("-s", "--album-separator", default=" ", type=str, help="Separator string to use for compound album names created from nested folders. Only effective if -a is set to a value > 1") parser.add_argument("-c", "--chunk-size", default=2000, type=int, help="Maximum number of assets to add to an album with a single API call") parser.add_argument("-C", "--fetch-chunk-size", default=5000, type=int, help="Maximum number of assets to fetch with a single API call") @@ -30,6 +39,8 @@ number_of_assets_to_fetch_per_request = args["fetch_chunk_size"] unattended = args["unattended"] album_levels = args["album_levels"] +# Album Levels Range handling +album_levels_range_arr = () album_level_separator = args["album_separator"] logging.debug("root_path = %s", root_paths) logging.debug("root_url = %s", root_url) @@ -37,14 +48,46 @@ logging.debug("number_of_images_per_request = %d", number_of_images_per_request) logging.debug("number_of_assets_to_fetch_per_request = %d", number_of_assets_to_fetch_per_request) logging.debug("unattended = %s", unattended) -logging.debug("album_levels = %d", album_levels) +logging.debug("album_levels = %s", album_levels) +#logging.debug("album_levels_range = %s", album_levels_range) logging.debug("album_level_separator = %s", album_level_separator) # Verify album levels -if album_levels == 0: +if is_integer(album_levels) and album_levels == 0: parser.print_help() exit(1) +# Verify album levels range +if not is_integer(album_levels): + album_levels_range_split = album_levels.split(",") + if (len(album_levels_range_split) != 2 + or not is_integer(album_levels_range_split[0]) + or not is_integer(album_levels_range_split[1]) + or int(album_levels_range_split[0]) == 0 + or int(album_levels_range_split[1]) == 0 + or (int(album_levels_range_split[0]) >= 0 and int(album_levels_range_split[1]) < 0) + or (int(album_levels_range_split[0]) < 0 and int(album_levels_range_split[1]) >= 0) + or (int(album_levels_range_split[0]) < 0 and int(album_levels_range_split[1]) < 0) and int(album_levels_range_split[0]) > int(album_levels_range_split[1])): + logging.error("Invalid album_levels range format! If a range should be set, the start level and end level must be separated by a comma like ','. If negative levels are used in a range, must be less than or equal to .") + exit(1) + album_levels_range_arr = album_levels_range_split + # Convert to int + album_levels_range_arr[0] = int(album_levels_range_split[0]) + album_levels_range_arr[1] = int(album_levels_range_split[1]) + # Special case: both levels are negative and end level is -1, which is equivalent to just negative album level of start level + if(album_levels_range_arr[0] < 0 and album_levels_range_arr[1] == -1): + album_levels = album_levels_range_arr[0] + album_levels_range_arr = () + logging.debug("album_levels is a range with negative start level and end level of -1, converted to album_levels = %d", album_levels) + else: + logging.debug("valid album_levels range argument supplied") + logging.debug("album_levels_start_level = %d", album_levels_range_arr[0]) + logging.debug("album_levels_end_level = %d", album_levels_range_arr[1]) + # Deduct 1 from album start levels, since album levels start at 1 for user convenience, but arrays start at index 0 + if album_levels_range_arr[0] > 0: + album_levels_range_arr[0] -= 1 + album_levels_range_arr[1] -= 1 + # Yield successive n-sized # chunks from l. def divide_chunks(l, n): @@ -53,6 +96,50 @@ def divide_chunks(l, n): for i in range(0, len(l), n): yield l[i:i + n] +# Create album names from provided path_chunks string array +# based on supplied album_levels argument (either by level range or absolute album levels) +def create_album_name(path_chunks): + album_name_chunks = () + logging.debug("path chunks = %s", list(path_chunks)) + # Check which path to take: album_levels_range or album_levels + if len(album_levels_range_arr) == 2: + if album_levels_range_arr[0] < 0: + album_levels_start_level_capped = min(len(path_chunks), abs(album_levels_range_arr[0])) + album_levels_end_level_capped = album_levels_range_arr[1]+1 + album_levels_start_level_capped *= -1 + else: + album_levels_start_level_capped = min(len(path_chunks)-1, album_levels_range_arr[0]) + # Add 1 to album_levels_end_level_capped to include the end index, which is what the user intended to. It's not a problem + # if the end index is out of bounds. + album_levels_end_level_capped = min(len(path_chunks)-1, album_levels_range_arr[1]) + 1 + logging.debug("album_levels_start_level_capped = %d", album_levels_start_level_capped) + logging.debug("album_levels_end_level_capped = %d", album_levels_end_level_capped) + # album start level is not equal to album end level, so we want a range of levels + if album_levels_start_level_capped is not album_levels_end_level_capped: + + # if the end index is out of bounds. + if album_levels_end_level_capped < 0 and abs(album_levels_end_level_capped) >= len(path_chunks): + album_name_chunks = path_chunks[album_levels_start_level_capped:] + else: + album_name_chunks = path_chunks[album_levels_start_level_capped:album_levels_end_level_capped] + # album start and end levels are equal, we want exactly that level + else: + # create on-the-fly array with a single element taken from + album_name_chunks = [path_chunks[album_levels_start_level_capped]] + else: + album_levels_int = int(album_levels) + # either use as many path chunks as we have, + # or the specified album levels + album_name_chunk_size = min(len(path_chunks), abs(album_levels_int)) + if album_levels_int < 0: + album_name_chunk_size *= -1 + + # Copy album name chunks from the path to use as album name + album_name_chunks = path_chunks[:album_name_chunk_size] + if album_name_chunk_size < 0: + album_name_chunks = path_chunks[album_name_chunk_size:] + logging.debug("album_name_chunks = %s", album_name_chunks) + return album_level_separator.join(album_name_chunks) requests_kwargs = { 'headers' : { @@ -108,21 +195,11 @@ def divide_chunks(l, n): # remove last item from path chunks, which is the file name del path_chunks[-1] - album_name_chunks = () - # either use as many path chunks as we have, - # or the specified album levels - album_name_chunk_size = min(len(path_chunks), album_levels) - if album_levels < 0: - album_name_chunk_size = min(len(path_chunks), abs(album_levels))*-1 - - # Copy album name chunks from the path to use as album name - album_name_chunks = path_chunks[:album_name_chunk_size] - if album_name_chunk_size < 0: - album_name_chunks = path_chunks[album_name_chunk_size:] - - album_name = album_level_separator.join(album_name_chunks) - # Check that the extracted album name is not actually a file name in root_path - album_to_assets[album_name].append(asset['id']) + album_name = create_album_name(path_chunks) + if len(album_name) > 0: + album_to_assets[album_name].append(asset['id']) + else: + logging.warning("Got empty album name for asset path %s, check your album_level settings!", asset_path) album_to_assets = {k:v for k, v in sorted(album_to_assets.items(), key=(lambda item: item[0]))}