Skip to content

Commit

Permalink
Added support for album level ranges to support creating album names …
Browse files Browse the repository at this point in the history
…from only specific ranges of the folder structure
  • Loading branch information
Salvoxia committed May 10, 2024
1 parent 8c9d0a1 commit 891ea9d
Showing 1 changed file with 95 additions and 18 deletions.
113 changes: 95 additions & 18 deletions immich_auto_album.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,25 @@
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")
parser.add_argument("api_url", help="The root API URL of immich, e.g. https://immich.mydomain.com/api/")
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 '<startLevel>,<endLevel>'. If negative levels are used in a range, <startLevel> must be less than or equal to <endLevel>.")
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")
Expand All @@ -30,21 +39,55 @@
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)
logging.debug("api_key = %s", api_key)
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 '<startLevel>,<endLevel>'. If negative levels are used in a range, <startLevel> must be less than or equal to <endLevel>.")
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):
Expand All @@ -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' : {
Expand Down Expand Up @@ -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]))}

Expand Down

0 comments on commit 891ea9d

Please sign in to comment.