2024-02-24 13:15:57 +00:00
import requests
import os
import argparse
import logging
import sys
import datetime
2024-05-10 17:01:39 +00:00
import array as arr
2024-02-24 13:15:57 +00:00
from collections import defaultdict
2024-05-10 17:01:39 +00:00
# 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
2024-02-24 13:15:57 +00:00
parser = argparse . ArgumentParser ( description = " Create Immich Albums from an external library path based on the top level folders " , formatter_class = argparse . ArgumentDefaultsHelpFormatter )
2024-04-09 19:00:04 +00:00
parser . add_argument ( " root_path " , action = ' append ' , help = " The external libarary ' s root path in Immich " )
2024-02-24 13:15:57 +00:00
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 " )
2024-04-09 19:00:04 +00:00
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. " )
2024-02-24 13:15:57 +00:00
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. " )
2024-05-10 17:01:39 +00:00
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>. " )
2024-03-02 18:56:18 +00:00
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 " )
2024-02-24 13:15:57 +00:00
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 " )
parser . add_argument ( " -l " , " --log-level " , default = " INFO " , choices = [ ' CRITICAL ' , ' ERROR ' , ' WARNING ' , ' INFO ' , ' DEBUG ' ] , help = " Log level to use " )
args = vars ( parser . parse_args ( ) )
# set up logger to log in logfmt format
logging . basicConfig ( level = args [ " log_level " ] , stream = sys . stdout , format = ' time= %(asctime)s level= %(levelname)s msg= %(message)s ' )
logging . Formatter . formatTime = ( lambda self , record , datefmt = None : datetime . datetime . fromtimestamp ( record . created , datetime . timezone . utc ) . astimezone ( ) . isoformat ( sep = " T " , timespec = " milliseconds " ) )
2024-04-09 19:00:04 +00:00
root_paths = args [ " root_path " ]
2024-02-24 13:15:57 +00:00
root_url = args [ " api_url " ]
api_key = args [ " api_key " ]
number_of_images_per_request = args [ " chunk_size " ]
number_of_assets_to_fetch_per_request = args [ " fetch_chunk_size " ]
unattended = args [ " unattended " ]
2024-03-02 19:36:15 +00:00
album_levels = args [ " album_levels " ]
2024-05-10 17:01:39 +00:00
# Album Levels Range handling
album_levels_range_arr = ( )
2024-03-02 18:49:26 +00:00
album_level_separator = args [ " album_separator " ]
2024-04-09 19:00:04 +00:00
logging . debug ( " root_path = %s " , root_paths )
2024-03-03 17:52:22 +00:00
logging . debug ( " root_url = %s " , root_url )
2024-02-24 13:15:57 +00:00
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 )
2024-05-10 17:01:39 +00:00
logging . debug ( " album_levels = %s " , album_levels )
#logging.debug("album_levels_range = %s", album_levels_range)
2024-03-02 19:32:44 +00:00
logging . debug ( " album_level_separator = %s " , album_level_separator )
2024-02-24 13:15:57 +00:00
2024-03-02 19:36:15 +00:00
# Verify album levels
2024-05-10 17:01:39 +00:00
if is_integer ( album_levels ) and album_levels == 0 :
2024-03-02 19:36:15 +00:00
parser . print_help ( )
exit ( 1 )
2024-05-10 17:01:39 +00:00
# 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
2024-02-24 13:15:57 +00:00
# Yield successive n-sized
# chunks from l.
def divide_chunks ( l , n ) :
# looping till length l
for i in range ( 0 , len ( l ) , n ) :
yield l [ i : i + n ]
2024-05-10 17:01:39 +00:00
# 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 )
2024-02-24 13:15:57 +00:00
2024-06-11 17:06:44 +00:00
# Fetches assets from the Immich API
# Takes different API versions into account for compatibility
def fetchAssets ( ) :
assets = [ ]
# Initial API call, let's fetch our first chunk
r = requests . get ( root_url + ' asset?take= ' + str ( number_of_assets_to_fetch_per_request ) , * * requests_kwargs )
assert r . status_code == 200
logging . debug ( " Received %s assets with chunk 1 " , len ( r . json ( ) ) )
assets = assets + r . json ( )
# If we got a full chunk size back, let's perfrom subsequent calls until we get less than a full chunk size
skip = 0
while len ( r . json ( ) ) == number_of_assets_to_fetch_per_request :
skip + = number_of_assets_to_fetch_per_request
r = requests . get ( root_url + ' asset?take= ' + str ( number_of_assets_to_fetch_per_request ) + ' &skip= ' + str ( skip ) , * * requests_kwargs )
if skip == number_of_assets_to_fetch_per_request and assets == r . json ( ) :
logging . info ( " Non-chunked Immich API detected, stopping fetching assets since we already got all in our first call " )
break
assert r . status_code == 200
logging . debug ( " Received %s assets with chunk " , len ( r . json ( ) ) )
assets = assets + r . json ( )
return assets
2024-02-24 13:15:57 +00:00
requests_kwargs = {
' headers ' : {
' x-api-key ' : api_key ,
' Content-Type ' : ' application/json ' ,
' Accept ' : ' application/json '
}
}
2024-04-09 19:00:04 +00:00
# append trailing slash to all root paths
for i in range ( len ( root_paths ) ) :
if root_paths [ i ] [ - 1 ] != ' / ' :
root_paths [ i ] = root_paths [ i ] + ' / '
# append trailing slash to root URL
2024-02-24 13:15:57 +00:00
if root_url [ - 1 ] != ' / ' :
root_url = root_url + ' / '
logging . info ( " Requesting all assets " )
2024-06-11 17:06:44 +00:00
assets = fetchAssets ( )
2024-02-24 13:15:57 +00:00
logging . info ( " %d photos found " , len ( assets ) )
logging . info ( " Sorting assets to corresponding albums using folder name " )
album_to_assets = defaultdict ( list )
for asset in assets :
asset_path = asset [ ' originalPath ' ]
2024-04-09 19:00:04 +00:00
for root_path in root_paths :
if root_path not in asset_path :
continue
# Chunks of the asset's path below root_path
path_chunks = asset_path . replace ( root_path , ' ' ) . split ( ' / ' )
# A single chunk means it's just the image file in no sub folder, ignore
if len ( path_chunks ) == 1 :
continue
2024-04-11 20:26:23 +00:00
# remove last item from path chunks, which is the file name
del path_chunks [ - 1 ]
2024-05-10 17:01:39 +00:00
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 )
2024-02-24 13:15:57 +00:00
album_to_assets = { k : v for k , v in sorted ( album_to_assets . items ( ) , key = ( lambda item : item [ 0 ] ) ) }
logging . info ( " %d albums identified " , len ( album_to_assets ) )
logging . info ( " Album list: %s " , list ( album_to_assets . keys ( ) ) )
if not unattended :
print ( " Press Enter to continue, Ctrl+C to abort " )
input ( )
album_to_id = { }
logging . info ( " Listing existing albums on immich " )
r = requests . get ( root_url + ' album ' , * * requests_kwargs )
assert r . status_code == 200
albums = r . json ( )
album_to_id = { album [ ' albumName ' ] : album [ ' id ' ] for album in albums }
logging . info ( " %d existing albums identified " , len ( albums ) )
logging . info ( " Creating albums if needed " )
cpt = 0
for album in album_to_assets :
if album in album_to_id :
continue
data = {
' albumName ' : album ,
' description ' : album
}
r = requests . post ( root_url + ' album ' , json = data , * * requests_kwargs )
assert r . status_code in [ 200 , 201 ]
album_to_id [ album ] = r . json ( ) [ ' id ' ]
logging . info ( ' Album %s added! ' , album )
cpt + = 1
logging . info ( " %d albums created " , cpt )
logging . info ( " Adding assets to albums " )
# Note: Immich manages duplicates without problem,
# so we can each time ad all assets to same album, no photo will be duplicated
for album , assets in album_to_assets . items ( ) :
id = album_to_id [ album ]
# Divide our assets into chunks of number_of_images_per_request,
# So the API can cope
assets_chunked = list ( divide_chunks ( assets , number_of_images_per_request ) )
for assets_chunk in assets_chunked :
data = { ' ids ' : assets_chunk }
r = requests . put ( root_url + f ' album/ { id } /assets ' , json = data , * * requests_kwargs )
if r . status_code not in [ 200 , 201 ] :
print ( album )
print ( r . json ( ) )
print ( data )
continue
assert r . status_code in [ 200 , 201 ]
response = r . json ( )
cpt = 0
for res in response :
if not res [ ' success ' ] :
if res [ ' error ' ] != ' duplicate ' :
logging . warning ( " Error adding an asset to an album: %s " , res [ ' error ' ] )
else :
cpt + = 1
if cpt > 0 :
logging . info ( " %d new assets added to %s " , cpt , album )
logging . info ( " Done! " )