From 891ea9d3a202aa597d763414c7e1c0135733581f Mon Sep 17 00:00:00 2001 From: Salvoxia Date: Fri, 10 May 2024 19:01:39 +0200 Subject: [PATCH 01/10] 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 argparse 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_url", help="The root API URL of immich, e.g. https://im 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_images_per_request = args["chunk_size"] 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("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 ','. 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 @@ for asset in assets: # 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]))} From a6aa999b28a7c8a857bd5fbc0e8bf8165c41bdce Mon Sep 17 00:00:00 2001 From: Salvoxia Date: Tue, 14 May 2024 18:56:01 +0200 Subject: [PATCH 02/10] README Updated compatibility statement with 1.104.x --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b8d751c..2c5c971 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This is a python script designed to automatically create albums in [Immich](http This is useful for automatically creating and populating albums for external libraries. Using the provided docker image, the script can simply be added to the Immich compose stack and run along the rest of Immich's containers. -__Current compatibility:__ Immich v1.103.x and below +__Current compatibility:__ Immich v1.104.x and below ## Disclaimer This script is mostly based on the following original script: [REDVM/immich_auto_album.py](https://gist.github.com/REDVM/d8b3830b2802db881f5b59033cf35702) From 945cbbc406cc5004ace7d6598136bff3c4063ef8 Mon Sep 17 00:00:00 2001 From: Salvoxia Date: Tue, 14 May 2024 20:43:01 +0200 Subject: [PATCH 03/10] README Updated compatibility statement with 1.105.x --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2c5c971..786b9ca 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This is a python script designed to automatically create albums in [Immich](http This is useful for automatically creating and populating albums for external libraries. Using the provided docker image, the script can simply be added to the Immich compose stack and run along the rest of Immich's containers. -__Current compatibility:__ Immich v1.104.x and below +__Current compatibility:__ Immich v1.105.x and below ## Disclaimer This script is mostly based on the following original script: [REDVM/immich_auto_album.py](https://gist.github.com/REDVM/d8b3830b2802db881f5b59033cf35702) From a7b8f060c661f3921c8ee809f772a72c79174f0c Mon Sep 17 00:00:00 2001 From: Salvoxia Date: Fri, 17 May 2024 18:55:49 +0200 Subject: [PATCH 04/10] README Added documentation for album level ranges --- README.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 786b9ca..2c20e49 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ options: Additional external libarary root path in Immich; May be specified multiple times for multiple import paths or external libraries. (default: None) -u, --unattended Do not ask for user confirmation after identifying albums. Set this flag to run script as a cronjob. (default: False) -a ALBUM_LEVELS, --album-levels ALBUM_LEVELS - 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. (default: 1) + 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 . (default: 1) -s ALBUM_SEPARATOR, --album-separator ALBUM_SEPARATOR Separator string to use for compound album names created from nested folders. Only effective if -a is set to a value > 1 (default: ) -c CHUNK_SIZE, --chunk-size CHUNK_SIZE @@ -62,7 +63,7 @@ The environment variables are analoguous to the script's command line arguments. | ROOT_PATH | yes | A single or a comma separated list of import paths for external libraries in Immich | | API_URL | yes | The root API URL of immich, e.g. https://immich.mydomain.com/api/ | | API_KEY | yes | The Immich API Key to use | -| ALBUM_LEVELS | no | 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. Refer to [How it works](#how-it-works) for a detailed explanation| +| ALBUM_LEVELS | no | 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. Refer to [How it works](#how-it-works) for a detailed explanation| | ALBUM_SEPARATOR | no | Separator string to use for compound album names created from nested folders. Only effective if -a is set to a value > 1 (default: " ") | | CHUNK_SIZE | no | Maximum number of assets to add to an album with a single API call (default: 2000) | | FETCH_CHUNK_SIZE | no | Maximum number of assets to fetch with a single API call (default: 5000) | @@ -179,7 +180,24 @@ Albums created for `root_path = /external_libs/photos/Birthdays`: - `Jane` (containing all imags from `Birthdays/Jane`) - `Skiing 2023` - Note that with negative `album-levels` images from different parent folders will be mixed in the same album if they reside in folders with the same name (see `Vacation` in example above). + ### Album Level Ranges + + It is possible to specify not just a nunmber for `album-levels`, but a range from level x to level y in the folder structure that should make up an album's name: + `--album-levels="2,3"` + The range is applied to the folder structure beneath `root_path` from the top for positive levels and from the bottom for negative levels. + Suppose the following folder structure for an external library with the script's `root_path` set to `/external_libs/photos`: + ``` +/external_libs/photos/2020/2020 02 Feb/Vacation +/external_libs/photos/2020/2020 08 Aug/Vacation +``` + - `--album-levels="2,3"` will create albums (for this folder structure, this is equal to `--album-levels="-2"`) + - `2020 02 Feb Facation` + - `2020 08 Aug Vacation` + - `--album-levels="2,2"` will create albums (for this folder structure, this is equal to `--album-levels="-2,-2"`) + - `2020 02 Feb` + - `2020 08 Aug` + +⚠️ Note that with negative `album-levels` or album level ranges, images from different parent folders will be mixed in the same album if they reside in sub-folders with the same name (see `Vacation` in example above). Since Immich does not support real nested albums ([yet?](https://github.com/immich-app/immich/discussions/2073)), neither does this script. From 8b9c7722d9086551cdaf21d09aba8580deb231ff Mon Sep 17 00:00:00 2001 From: Salvoxia Date: Wed, 5 Jun 2024 21:20:58 +0200 Subject: [PATCH 05/10] README Added missing CRON_EXPRESSION environment variable to documentation --- README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2c20e49..0f82eee 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +[![Docker][docker-image]][docker-url] + +[docker-image]: https://img.shields.io/docker/pulls/salvoxia/immich-folder-album-creator.svg +[docker-url]: https://hub.docker.com/r/salvoxia/immich-folder-album-creator/ + # Immich Folder Album Creator This is a python script designed to automatically create albums in [Immich](https://immich.app/) from a folder structure mounted into the Immich container. @@ -60,10 +65,11 @@ The environment variables are analoguous to the script's command line arguments. | Environment varible | Mandatory? | Description | | :------------------- | :----------- | :------------ | -| ROOT_PATH | yes | A single or a comma separated list of import paths for external libraries in Immich | -| API_URL | yes | The root API URL of immich, e.g. https://immich.mydomain.com/api/ | -| API_KEY | yes | The Immich API Key to use | -| ALBUM_LEVELS | no | 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. Refer to [How it works](#how-it-works) for a detailed explanation| +| ROOT_PATH | yes | A single or a comma separated list of import paths for external libraries in Immich´| +| API_URL | yes | The root API URL of immich, e.g. https://immich.mydomain.com/api/ | +| API_KEY | yes | The Immich API Key to use +| CRON_EXPRESSION | yes | A [crontab-style expression](https://crontab.guru/) (e.g. "0 * * * *") to perform album creation on a schedule (e.g. every hour). | +| ALBUM_LEVELS | no | 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. Refer to [How it works](#how-it-works) for a detailed explanation | | ALBUM_SEPARATOR | no | Separator string to use for compound album names created from nested folders. Only effective if -a is set to a value > 1 (default: " ") | | CHUNK_SIZE | no | Maximum number of assets to add to an album with a single API call (default: 2000) | | FETCH_CHUNK_SIZE | no | Maximum number of assets to fetch with a single API call (default: 5000) | From aab24346d17f07d31d853909e4a4869f8b2bfe44 Mon Sep 17 00:00:00 2001 From: Salvoxia Date: Tue, 11 Jun 2024 19:06:44 +0200 Subject: [PATCH 06/10] Moved fetching assets into a function as preparation for supporting API changes in 1.106.1 --- immich_auto_album.py | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/immich_auto_album.py b/immich_auto_album.py index c810cbf..2b9deaa 100644 --- a/immich_auto_album.py +++ b/immich_auto_album.py @@ -141,6 +141,29 @@ def create_album_name(path_chunks): logging.debug("album_name_chunks = %s", album_name_chunks) return album_level_separator.join(album_name_chunks) +# 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 + requests_kwargs = { 'headers' : { 'x-api-key': api_key, @@ -158,24 +181,7 @@ if root_url[-1] != '/': root_url = root_url + '/' logging.info("Requesting all assets") -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() +assets = fetchAssets() logging.info("%d photos found", len(assets)) From 5618dd3b6f7bb9d68efca62c306ebaf9cd56c157 Mon Sep 17 00:00:00 2001 From: Salvoxia Date: Tue, 11 Jun 2024 20:41:23 +0200 Subject: [PATCH 07/10] Refactoring Moved API calls to functions for support of legacy API calls (might be removed in the future) Added support for API with Immich Server v1.106.1+ --- immich_auto_album.py | 170 ++++++++++++++++++++++++++++++++----------- 1 file changed, 129 insertions(+), 41 deletions(-) diff --git a/immich_auto_album.py b/immich_auto_album.py index 2b9deaa..89f651c 100644 --- a/immich_auto_album.py +++ b/immich_auto_album.py @@ -5,6 +5,7 @@ import logging import sys import datetime import array as arr +import json from collections import defaultdict # Trying to deal with python's isnumeric() function @@ -87,7 +88,17 @@ if not is_integer(album_levels): if album_levels_range_arr[0] > 0: album_levels_range_arr[0] -= 1 album_levels_range_arr[1] -= 1 - + + +# Request arguments for API calls +requests_kwargs = { + 'headers' : { + 'x-api-key': api_key, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } +} + # Yield successive n-sized # chunks from l. def divide_chunks(l, n): @@ -141,9 +152,34 @@ def create_album_name(path_chunks): logging.debug("album_name_chunks = %s", album_name_chunks) return album_level_separator.join(album_name_chunks) +# Fetches assets from the Immich API +# Takes different API versions into account for compatibility +def fetchServerVersion(): + # This API call was only introduced with version 1.106.1, so it will fail + # for older versions. + # Initialize the version with the latest version without this API call + version = {'major': 1, 'minor': 105, "patch": 1} + r = requests.get(root_url+'server-info/version', **requests_kwargs) + assert r.status_code == 200 or r.status_code == 404 + if r.status_code == 200: + version = r.json() + logging.info("Detected Immich server version %s.%s.%s", version['major'], version['minor'], version['patch']) + else: + logging.info("Detected Immich server version %s.%s.%s or older", version['major'], version['minor'], version['patch']) + return version + # Fetches assets from the Immich API # Takes different API versions into account for compatibility def fetchAssets(): + if version['major'] == 1 and version['minor'] <= 105: + return fetchAssetsLegacy() + else: + return fetchAssetsMinorV106() + + +# Fetches assets from the Immich API +# Uses the legacy GET /asset call which only exists up to v1.105.x +def fetchAssetsLegacy(): 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) @@ -164,13 +200,93 @@ def fetchAssets(): assets = assets + r.json() return assets -requests_kwargs = { - 'headers' : { - 'x-api-key': api_key, - 'Content-Type': 'application/json', - 'Accept': 'application/json' +# Fetches assets from the Immich API +# Uses the /search/meta-data call. Much more efficient than the legacy method +# since this call allows to filter for assets that are not in an album only. +def fetchAssetsMinorV106(): + assets = [] + # prepare request body + body = {} + #body['isNotInAlbum'] = 'false' + # This API call allows a maximum page size of 1000 + number_of_assets_to_fetch_per_request_search = min(1000, number_of_assets_to_fetch_per_request) + body['size'] = number_of_assets_to_fetch_per_request_search + # Initial API call, let's fetch our first chunk + page = 1 + body['page'] = str(page) + r = requests.post(root_url+'search/metadata', json=body, **requests_kwargs) + r.raise_for_status() + responseJson = r.json() + assetsReceived = responseJson['assets']['items'] + logging.debug("Received %s assets with chunk %s", len(assetsReceived), page) + + assets = assets + assetsReceived + # If we got a full chunk size back, let's perfrom subsequent calls until we get less than a full chunk size + while len(assetsReceived) == number_of_assets_to_fetch_per_request_search: + page += 1 + body['page'] = page + r = requests.post(root_url+'search/metadata', json=body, **requests_kwargs) + assert r.status_code == 200 + responseJson = r.json() + assetsReceived = responseJson['assets']['items'] + logging.debug("Received %s assets with chunk %s", len(assetsReceived), page) + assets = assets + assetsReceived + return assets + + +# Fetches assets from the Immich API +# Takes different API versions into account for compatibility +def fetchAlbums(): + apiEndpoint = 'albums' + if version['major'] == 1 and version['minor'] <= 105: + apiEndpoint = 'album' + + r = requests.get(root_url+apiEndpoint, **requests_kwargs) + r.raise_for_status() + return r.json() + +# Creates an album with the provided name and returns the ID of the +# created album +def createAlbum(albumName): + apiEndpoint = 'albums' + if version['major'] == 1 and version['minor'] <= 105: + apiEndpoint = 'album' + data = { + 'albumName': albumName, + 'description': albumName } -} + r = requests.post(root_url+apiEndpoint, json=data, **requests_kwargs) + assert r.status_code in [200, 201] + return r.json()['id'] + +# Adds the provided assetIds to the provided albumId +def addAssetsToAlbum(albumId, assets): + apiEndpoint = 'albums' + if version['major'] == 1 and version['minor'] <= 105: + apiEndpoint = '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+apiEndpoint+f'/{albumId}/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) # append trailing slash to all root paths for i in range(len(root_paths)): @@ -180,6 +296,8 @@ for i in range(len(root_paths)): if root_url[-1] != '/': root_url = root_url + '/' +version = fetchServerVersion() + logging.info("Requesting all assets") assets = fetchAssets() logging.info("%d photos found", len(assets)) @@ -219,9 +337,8 @@ if not unattended: 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() + +albums = fetchAlbums() album_to_id = {album['albumName']:album['id'] for album in albums } logging.info("%d existing albums identified", len(albums)) @@ -231,13 +348,7 @@ 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'] + album_to_id[album] = createAlbum(album) logging.info('Album %s added!', album) cpt += 1 logging.info("%d albums created", cpt) @@ -248,29 +359,6 @@ logging.info("Adding assets to albums") # 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) + addAssetsToAlbum(id, assets) logging.info("Done!") From 0b476ce2305b970d82b0b42bf068fc666f0437e9 Mon Sep 17 00:00:00 2001 From: Salvoxia Date: Tue, 11 Jun 2024 20:44:52 +0200 Subject: [PATCH 08/10] Immich 1.106.1+ Enabled fitler to fetch only assets that are not in any album --- immich_auto_album.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/immich_auto_album.py b/immich_auto_album.py index 89f651c..b785fd7 100644 --- a/immich_auto_album.py +++ b/immich_auto_album.py @@ -207,7 +207,7 @@ def fetchAssetsMinorV106(): assets = [] # prepare request body body = {} - #body['isNotInAlbum'] = 'false' + body['isNotInAlbum'] = 'true' # This API call allows a maximum page size of 1000 number_of_assets_to_fetch_per_request_search = min(1000, number_of_assets_to_fetch_per_request) body['size'] = number_of_assets_to_fetch_per_request_search From 2f07b0ae4ddc013ed75c85743403eca23233964b Mon Sep 17 00:00:00 2001 From: Salvoxia Date: Tue, 11 Jun 2024 20:45:51 +0200 Subject: [PATCH 09/10] README Updated compatibility statement with 1.106.x --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0f82eee..3561ad5 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ This is a python script designed to automatically create albums in [Immich](http This is useful for automatically creating and populating albums for external libraries. Using the provided docker image, the script can simply be added to the Immich compose stack and run along the rest of Immich's containers. -__Current compatibility:__ Immich v1.105.x and below +__Current compatibility:__ Immich v1.106.x and below ## Disclaimer This script is mostly based on the following original script: [REDVM/immich_auto_album.py](https://gist.github.com/REDVM/d8b3830b2802db881f5b59033cf35702) From f29ab5fb9a3cdb8ea99e1c81c098fd84e54fc1a1 Mon Sep 17 00:00:00 2001 From: Salvoxia Date: Tue, 11 Jun 2024 20:48:50 +0200 Subject: [PATCH 10/10] Cleaned up imports --- immich_auto_album.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/immich_auto_album.py b/immich_auto_album.py index b785fd7..b2dda18 100644 --- a/immich_auto_album.py +++ b/immich_auto_album.py @@ -1,11 +1,8 @@ import requests -import os import argparse import logging import sys import datetime -import array as arr -import json from collections import defaultdict # Trying to deal with python's isnumeric() function