├── .gitignore ├── requirements.txt ├── .pylintrc ├── docker ├── setup_cron.sh └── immich_auto_album.sh ├── Dockerfile ├── .github └── workflows │ ├── ci.yaml │ └── build-image.yaml ├── README.md └── immich_auto_album.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.idea 2 | .vscode/* 3 | 4 | .history -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | urllib3 3 | pyyaml 4 | regex==2024.11.6 -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [BASIC] 2 | 3 | # Good variable names which should always be accepted, separated by a comma 4 | good-names=e,ex,id,r,i,j 5 | 6 | [FORMAT] 7 | 8 | # Maximum number of characters on a single line. 9 | max-line-length=200 10 | 11 | # Maximum number of lines in a module 12 | max-module-lines=2500 -------------------------------------------------------------------------------- /docker/setup_cron.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | if [ ! -z "$RUN_IMMEDIATELY" ] && { [ "$RUN_IMMEDIATELY" = "true" ] || [ "$RUN_IMMEDIATELY" = "1" ]; }; then 3 | UNATTENDED=1 /script/immich_auto_album.sh > /proc/1/fd/1 2>/proc/1/fd/2 || true 4 | fi 5 | if [ ! -z "$CRON_EXPRESSION" ]; then 6 | CRONTAB_PATH="$CRONTAB_DIR/crontab" 7 | # Create and lock down crontab 8 | touch "$CRONTAB_PATH" 9 | chmod 0600 "$CRONTAB_PATH" 10 | # populate crontab 11 | echo "$CRON_EXPRESSION UNATTENDED=1 /script/immich_auto_album.sh > /proc/1/fd/1 2>/proc/1/fd/2" > "$CRONTAB_PATH" 12 | if [ "$LOG_LEVEL" == "DEBUG" ]; then 13 | DEBUG_PARM=-debug 14 | fi 15 | /usr/local/bin/supercronic -passthrough-logs -no-reap -split-logs $DEBUG_PARM $CRONTAB_PATH 16 | else 17 | /script/immich_auto_album.sh > /proc/1/fd/1 2>/proc/1/fd/2 || true 18 | fi 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-alpine3.22 2 | LABEL maintainer="Salvoxia " 3 | ARG TARGETPLATFORM 4 | 5 | # Latest releases available at https://github.com/aptible/supercronic/releases 6 | ENV SUPERCRONIC_URL_BASE=https://github.com/aptible/supercronic/releases/download/v0.2.39/supercronic \ 7 | SUPERCRONIC_BASE=supercronic \ 8 | CRONTAB_DIR=/script/cron \ 9 | IS_DOCKER=1 10 | 11 | COPY immich_auto_album.py requirements.txt docker/immich_auto_album.sh docker/setup_cron.sh /script/ 12 | 13 | # gcc and musl-dev are required for building requirements for regex python module 14 | RUN case "${TARGETPLATFORM}" in \ 15 | "linux/amd64") SUPERCRONIC_URL=$SUPERCRONIC_URL_BASE-linux-amd64 SUPERCRONIC=$SUPERCRONIC_BASE-linux-amd64 SUPERCRONIC_SHA1SUM=c98bbf82c5f648aaac8708c182cc83046fe48423 ;; \ 16 | "linux/arm64") SUPERCRONIC_URL=$SUPERCRONIC_URL_BASE-linux-arm SUPERCRONIC=$SUPERCRONIC_BASE-linux-arm SUPERCRONIC_SHA1SUM=8c3dbef8175e3f579baefe4e55978f2a27cb76b5 ;; \ 17 | "linux/arm/v7") SUPERCRONIC_URL=$SUPERCRONIC_URL_BASE-linux-arm64 SUPERCRONIC=$SUPERCRONIC_BASE-linux-arm64 SUPERCRONIC_SHA1SUM=5ef4ccc3d43f12d0f6c3763758bc37cc4e5af76e ;; \ 18 | *) exit 1 ;; \ 19 | esac; \ 20 | if [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then apk add gcc musl-dev; fi \ 21 | && apk add tini curl \ 22 | && pip install --no-cache-dir -r /script/requirements.txt \ 23 | && chmod +x /script/setup_cron.sh /script/immich_auto_album.sh \ 24 | && rm -rf /tmp/* /var/tmp/* /var/cache/apk/* /var/cache/distfiles/* \ 25 | && if [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then apk del gcc musl-dev; fi \ 26 | && curl -fsSLO "$SUPERCRONIC_URL" \ 27 | && echo "${SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - \ 28 | && chmod +x "$SUPERCRONIC" \ 29 | && mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \ 30 | && ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic \ 31 | && apk del curl \ 32 | # Prepare crontab 33 | && mkdir $CRONTAB_DIR \ 34 | && chmod 0777 $CRONTAB_DIR 35 | 36 | WORKDIR /script 37 | 38 | USER 1000:1000 39 | 40 | ENTRYPOINT ["tini", "-s", "-g", "--", "/script/setup_cron.sh"] 41 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | 'on': 4 | pull_request: 5 | 6 | jobs: 7 | lint: 8 | runs-on: ubuntu-latest 9 | name: Lint 10 | strategy: 11 | matrix: 12 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v3 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install pylint 23 | pip install -r requirements.txt 24 | - name: Analysing the code with pylint 25 | run: | 26 | pylint $(git ls-files '*.py') 27 | docker: 28 | name: Build Docker Image 29 | needs: lint 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | 35 | - name: Convert repository name ot image name 36 | id: image_name 37 | run: | 38 | sed -E -e 's/docker-//' -e 's/^/image_name=/' <<<"${{ github.repository }}" >> "$GITHUB_OUTPUT" 39 | 40 | - name: Generate Docker tag names 41 | id: meta 42 | uses: docker/metadata-action@v5 43 | with: 44 | # list of Docker images to use as base name for tags 45 | images: | 46 | ghcr.io/${{ steps.image_name.outputs.image_name }} 47 | ${{ steps.image_name.outputs.image_name }} 48 | # generate Docker tags based on the following events/attributes 49 | tags: | 50 | # set edge tag for default branch 51 | type=edge,enable={{is_default_branch}} 52 | # set dev tag for dev branch 53 | type=raw,value=dev,enable=${{ github.ref == format('refs/heads/{0}', 'dev') }},branch=dev 54 | # set build-test tag for any branch not dev or the default one 55 | type=raw,value=build-test,enable=${{ github.ref != format('refs/heads/{0}', 'dev') && github.ref != format('refs/heads/{0}', github.event.repository.default_branch) }},branch=dev 56 | 57 | - name: Set up QEMU 58 | uses: docker/setup-qemu-action@v3 59 | 60 | - name: Set up Docker Buildx 61 | uses: docker/setup-buildx-action@v3 62 | 63 | - name: Build only 64 | uses: docker/build-push-action@v5 65 | with: 66 | context: . 67 | platforms: linux/arm/v7,linux/arm64/v8,linux/amd64 68 | # Push only for default branch or dev branch 69 | push: false 70 | tags: ${{ steps.meta.outputs.tags }} 71 | -------------------------------------------------------------------------------- /.github/workflows/build-image.yaml: -------------------------------------------------------------------------------- 1 | name: build-image 2 | 3 | 'on': 4 | push: 5 | paths-ignore: 6 | - 'README.md' 7 | branches: 8 | - main 9 | - dev 10 | tags: 11 | - '[0-9]+\.[0-9]+\.[0-9]+' 12 | 13 | jobs: 14 | lint: 15 | runs-on: ubuntu-latest 16 | name: Lint 17 | strategy: 18 | matrix: 19 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install pylint 30 | pip install -r requirements.txt 31 | - name: Analysing the code with pylint 32 | run: | 33 | pylint $(git ls-files '*.py') 34 | docker: 35 | name: Build Docker Image 36 | needs: lint 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: Checkout 40 | uses: actions/checkout@v4 41 | 42 | - name: Convert repository name ot image name 43 | id: image_name 44 | run: | 45 | sed -E -e 's/docker-//' -e 's/^/image_name=/' <<<"${{ github.repository }}" >> "$GITHUB_OUTPUT" 46 | 47 | - name: Generate Docker tag names 48 | id: meta 49 | uses: docker/metadata-action@v5 50 | with: 51 | # list of Docker images to use as base name for tags 52 | images: | 53 | ghcr.io/${{ steps.image_name.outputs.image_name }} 54 | ${{ steps.image_name.outputs.image_name }} 55 | # generate Docker tags based on the following events/attributes 56 | tags: | 57 | # set edge tag for default branch 58 | type=edge,enable={{is_default_branch}} 59 | # set dev tag for dev branch 60 | type=raw,value=dev,enable=${{ github.ref == format('refs/heads/{0}', 'dev') }},branch=dev 61 | # Tags for non SemVer tag names 62 | type=match,pattern=([0-9]+.*),group=1 63 | # latest tag for any tags 64 | type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }},event=tag 65 | 66 | - name: Set up QEMU 67 | uses: docker/setup-qemu-action@v3 68 | 69 | - name: Set up Docker Buildx 70 | uses: docker/setup-buildx-action@v3 71 | 72 | - name: Login to GHCR 73 | uses: docker/login-action@v3 74 | with: 75 | registry: ghcr.io 76 | username: ${{ github.repository_owner }} 77 | password: ${{ secrets.REGISTRY_TOKEN }} 78 | 79 | - name: Login to Docker Hub 80 | uses: docker/login-action@v3 81 | with: 82 | username: ${{ secrets.DOCKERHUB_USERNAME }} 83 | password: ${{ secrets.DOCKERHUB_TOKEN }} 84 | 85 | - name: Build and push 86 | uses: docker/build-push-action@v5 87 | with: 88 | context: . 89 | platforms: linux/arm/v7,linux/arm64/v8,linux/amd64 90 | push: true 91 | tags: ${{ steps.meta.outputs.tags }} 92 | -------------------------------------------------------------------------------- /docker/immich_auto_album.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # parse comma separated root paths and wrap in quotes 4 | oldIFS=$IFS 5 | IFS=',' 6 | # disable globbing 7 | set -f 8 | # parse ROOT_PATH CSV 9 | main_root_path="" 10 | additional_root_paths="" 11 | for path in ${ROOT_PATH}; do 12 | if [ -z "$main_root_path" ]; then 13 | main_root_path="\"$path\"" 14 | else 15 | additional_root_paths="--root-path \"$path\" $additional_root_paths" 16 | fi 17 | done 18 | IFS=$oldIFS 19 | 20 | # parse semicolon separated root paths and wrap in quotes 21 | oldIFS=$IFS 22 | IFS=':' 23 | # parse SHARE_WITH CSV 24 | share_with_list="" 25 | if [ ! -z "$SHARE_WITH" ]; then 26 | for share_user in ${SHARE_WITH}; do 27 | share_with_list="--share-with \"$share_user\" $share_with_list" 28 | done 29 | fi 30 | 31 | # parse PATH_FILTER CSV 32 | path_filter_list="" 33 | if [ ! -z "$PATH_FILTER" ]; then 34 | for path_filter_entry in ${PATH_FILTER}; do 35 | path_filter_list="--path-filter \"$path_filter_entry\" $path_filter_list" 36 | done 37 | fi 38 | 39 | # parse IGNORE CSV 40 | ignore_list="" 41 | if [ ! -z "$IGNORE" ]; then 42 | for ignore_entry in ${IGNORE}; do 43 | ignore_list="--ignore \"$ignore_entry\" $ignore_list" 44 | done 45 | fi 46 | 47 | # parse API_KEY CSV 48 | main_api_key="" 49 | additional_api_keys="" 50 | api_keys="" 51 | # Determine whether API keys are passed as literals or as paths to secret files 52 | if [ ! -z "$API_KEY" ]; then 53 | api_key_type="--api-key-type literal" 54 | api_keys=${API_KEY} 55 | elif [ ! -z "$API_KEY_FILE" ]; then 56 | api_key_type="--api-key-type file" 57 | api_keys=${API_KEY_FILE} 58 | fi 59 | 60 | for api_key in ${api_keys}; do 61 | if [ -z "$main_api_key" ]; then 62 | main_api_key="\"$api_key\"" 63 | else 64 | additional_api_keys="--api-key \"$api_key\" $additional_api_keys" 65 | fi 66 | done 67 | 68 | ## parse ABLUM_NAME_POST_REGEX 69 | # Split on newline only 70 | IFS=$(echo -en "\n\b") 71 | album_name_post_regex_list="" 72 | # Support up to 10 regex patterns 73 | regex_max=10 74 | for regex_no in `seq 1 $regex_max` 75 | do 76 | for entry in `env` 77 | do 78 | # check if env variable name begins with ALBUM_POST_NAME_REGEX followed by a the current regex no and and equal sign 79 | pattern=$(echo "^ALBUM_NAME_POST_REGEX${regex_no}+=.+") 80 | TEST=$(echo "${entry}" | grep -E "$pattern") 81 | if [ ! -z "${TEST}" ]; then 82 | value="${entry#*=}" # select everything after the first `=` 83 | album_name_post_regex_list="$album_name_post_regex_list --album-name-post-regex $value" 84 | fi 85 | done 86 | done 87 | 88 | # reset IFS 89 | IFS=$oldIFS 90 | 91 | unattended= 92 | if [ ! -z "$UNATTENDED" ]; then 93 | unattended="--unattended" 94 | fi 95 | 96 | 97 | 98 | args="$api_key_type $unattended $main_root_path $API_URL $main_api_key" 99 | 100 | if [ ! -z "$additional_root_paths" ]; then 101 | args="$additional_root_paths $args" 102 | fi 103 | 104 | if [ ! -z "$ALBUM_LEVELS" ]; then 105 | args="--album-levels=\"$ALBUM_LEVELS\" $args" 106 | fi 107 | 108 | if [ ! -z "$ALBUM_SEPARATOR" ]; then 109 | args="--album-separator \"$ALBUM_SEPARATOR\" $args" 110 | fi 111 | 112 | if [ ! -z "$album_name_post_regex_list" ]; then 113 | args="$album_name_post_regex_list $args" 114 | fi 115 | 116 | if [ ! -z "$FETCH_CHUNK_SIZE" ]; then 117 | args="--fetch-chunk-size $FETCH_CHUNK_SIZE $args" 118 | fi 119 | 120 | if [ ! -z "$CHUNK_SIZE" ]; then 121 | args="--chunk-size $CHUNK_SIZE $args" 122 | fi 123 | 124 | if [ ! -z "$LOG_LEVEL" ]; then 125 | args="--log-level $LOG_LEVEL $args" 126 | fi 127 | 128 | if [ "$INSECURE" = "true" ]; then 129 | args="--insecure $args" 130 | fi 131 | 132 | if [ ! -z "$ignore_list" ]; then 133 | args="$ignore_list $args" 134 | fi 135 | 136 | if [ ! -z "$additional_api_keys" ]; then 137 | args="$additional_api_keys $args" 138 | fi 139 | 140 | if [ ! -z "$MODE" ]; then 141 | args="--mode \"$MODE\" $args" 142 | fi 143 | 144 | if [ ! -z "$DELETE_CONFIRM" ]; then 145 | args="--delete-confirm $args" 146 | fi 147 | 148 | if [ ! -z "$share_with_list" ]; then 149 | args="$share_with_list $args" 150 | fi 151 | 152 | if [ ! -z "$SHARE_ROLE" ]; then 153 | args="--share-role $SHARE_ROLE $args" 154 | fi 155 | 156 | if [ ! -z "$SYNC_MODE" ]; then 157 | args="--sync-mode $SYNC_MODE $args" 158 | fi 159 | 160 | if [ ! -z "$ALBUM_ORDER" ]; then 161 | args="--album-order $ALBUM_ORDER $args" 162 | fi 163 | 164 | if [ ! -z "$FIND_ASSETS_IN_ALBUMS" ]; then 165 | args="--find-assets-in-albums $args" 166 | fi 167 | 168 | if [ ! -z "$FIND_ARCHIVED_ASSETS" ]; then 169 | args="--find-archived-assets $args" 170 | fi 171 | 172 | if [ ! -z "$path_filter_list" ]; then 173 | args="$path_filter_list $args" 174 | fi 175 | 176 | if [ ! -z "$SET_ALBUM_THUMBNAIL" ]; then 177 | args="--set-album-thumbnail \"$SET_ALBUM_THUMBNAIL\" $args" 178 | fi 179 | 180 | # Deprecated, will be removed in future release 181 | if [ ! -z "$ARCHIVE" ]; then 182 | args="--archive $args" 183 | fi 184 | 185 | if [ ! -z "$VISIBILITY" ]; then 186 | args="--visibility=\"$VISIBILITY\" $args" 187 | fi 188 | 189 | if [ ! -z "$READ_ALBUM_PROPERTIES" ]; then 190 | args="--read-album-properties $args" 191 | fi 192 | 193 | if [ ! -z "$API_TIMEOUT" ]; then 194 | args="--api-timeout \"$API_TIMEOUT\" $args" 195 | fi 196 | 197 | if [ "$COMMENTS_AND_LIKES" == "1" ]; then 198 | args="--comments-and-likes-enabled $args" 199 | elif [ "$COMMENTS_AND_LIKES" == "0" ]; then 200 | args="--comments-and-likes-disabled $args" 201 | fi 202 | 203 | if [ ! -z "$UPDATE_ALBUM_PROPS_MODE" ]; then 204 | args="--update-album-props-mode $UPDATE_ALBUM_PROPS_MODE $args" 205 | fi 206 | 207 | if [ ! -z "$MAX_RETRY_COUNT" ]; then 208 | args="--max-retry-count $MAX_RETRY_COUNT $args" 209 | fi 210 | 211 | BASEDIR=$(dirname "$0") 212 | echo $args | xargs python3 -u $BASEDIR/immich_auto_album.py 213 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/salvoxia/immich-folder-album-creator/workflows/CI/badge.svg)](https://github.com/Salvoxia/immich-folder-album-creator/actions/workflows/ci.yaml) 2 | [![Build Status](https://github.com/salvoxia/immich-folder-album-creator/workflows/build-image/badge.svg)](https://github.com/Salvoxia/immich-folder-album-creator/actions/workflows/build-image.yaml) 3 | [![Docker][docker-image]][docker-url] 4 | 5 | [docker-image]: https://img.shields.io/docker/pulls/salvoxia/immich-folder-album-creator.svg 6 | [docker-url]: https://hub.docker.com/r/salvoxia/immich-folder-album-creator/ 7 | 8 | # Immich Folder Album Creator 9 | 10 | This is a python script designed to automatically create albums in [Immich](https://immich.app/) from a folder structure mounted into the Immich container. 11 | This is useful for automatically creating and populating albums for external libraries. 12 | 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. 13 | 14 | __Current compatibility:__ Immich v1.106.1 - v2.2.x 15 | 16 | ### Disclaimer 17 | This script is mostly based on the following original script: [REDVM/immich_auto_album.py](https://gist.github.com/REDVM/d8b3830b2802db881f5b59033cf35702) 18 | 19 | ## Table of Contents 20 | - [Immich Folder Album Creator](#immich-folder-album-creator) 21 | - [Disclaimer](#disclaimer) 22 | - [Table of Contents](#table-of-contents) 23 | - [Usage](#usage) 24 | - [Creating an API Key](#creating-an-api-key) 25 | - [Bare Python Script](#bare-python-script) 26 | - [Docker](#docker) 27 | - [Environment Variables](#environment-variables) 28 | - [Run the container with Docker](#run-the-container-with-docker) 29 | - [Run the container with Docker-Compose](#run-the-container-with-docker-compose) 30 | - [Choosing the correct `root_path`](#choosing-the-correct-root_path) 31 | - [How it works](#how-it-works) 32 | - [Album Level Ranges](#album-level-ranges) 33 | - [Filtering](#filtering) 34 | - [Ignoring Assets](#ignoring-assets) 35 | - [Filtering for Assets](#filtering-for-assets) 36 | - [Filter Examples](#filter-examples) 37 | - [Album Name Regex](#album-name-regex) 38 | - [Regex Examples](#regex-examples) 39 | - [Automatic Album Sharing](#automatic-album-sharing) 40 | - [Album Sharing Examples (Bare Python Script)](#album-sharing-examples-bare-python-script) 41 | - [Album Sharing Examples (Docker)](#album-sharing-examples-docker) 42 | - [Cleaning Up Albums](#cleaning-up-albums) 43 | - [`CLEANUP`](#cleanup) 44 | - [`DELETE_ALL`](#delete_all) 45 | - [Assets in Multiple Albums](#assets-in-multiple-albums) 46 | - [Setting Album Thumbnails](#setting-album-thumbnails) 47 | - [Setting Album-Fine Properties](#setting-album-fine-properties) 48 | - [Prerequisites](#prerequisites) 49 | - [`.albumprops` File Format](#albumprops-file-format) 50 | - [Enabling `.albumprops` discovery](#enabling-albumprops-discovery) 51 | - [Property Precedence](#property-precedence) 52 | - [Mass Updating Album Properties](#mass-updating-album-properties) 53 | - [Examples:](#examples) 54 | - [Asset Visibility & Locked Folder](#asset-visibility--locked-folder) 55 | - [Dealing with External Library Changes](#dealing-with-external-library-changes) 56 | - [`docker-compose` example passing the API key as environment variable](#docker-compose-example-passing-the-api-key-as-environment-variable) 57 | - [`docker-compose` example using a secrets file for the API key](#docker-compose-example-using-a-secrets-file-for-the-api-key) 58 | 59 | ## Usage 60 | ### Creating an API Key 61 | Regardless of how the script will be used later ([Bare Python Script](#bare-python-script) or [Docker](#docker)), an API Key is required for each user the script should be used for. 62 | Since Immich Server v1.135.x, creating API keys allows the user to specify permissions. The following permissions are required for the script to work with any possible option. 63 | The list contains API key permissions valid for **Immich v2.1.0**. 64 | - `asset` 65 | - `asset.read` 66 | - `asset.delete` 67 | - `album` 68 | - `album.create` 69 | - `album.read` 70 | - `album.update` 71 | - `album.delete` 72 | - `albumAsset` 73 | - `albumAsset.create` 74 | - `albumUser` 75 | - `albumUser.create` 76 | - `albumUser.update` 77 | - `albumUser.delete` 78 | - `user` 79 | - `user.read` 80 | 81 | ### Bare Python Script 82 | 1. Download the script and its requirements 83 | ```bash 84 | curl https://raw.githubusercontent.com/Salvoxia/immich-folder-album-creator/main/immich_auto_album.py -o immich_auto_album.py 85 | curl https://raw.githubusercontent.com/Salvoxia/immich-folder-album-creator/main/requirements.txt -o requirements.txt 86 | ``` 87 | 2. Install requirements 88 | ```bash 89 | pip3 install -r requirements.txt 90 | ``` 91 | 3. Run the script 92 | ``` 93 | usage: immich_auto_album.py [-h] [--api-key API_KEY] [-t {literal,file}] [-r ROOT_PATH] [-u] [-a ALBUM_LEVELS] [-s ALBUM_SEPARATOR] [-R PATTERN [REPL ...]] [-c CHUNK_SIZE] [-C FETCH_CHUNK_SIZE] [-l {CRITICAL,ERROR,WARNING,INFO,DEBUG}] [-k] [-i IGNORE] 94 | [-m {CREATE,CLEANUP,DELETE_ALL}] [-d] [-x SHARE_WITH] [-o {editor,viewer}] [-S {0,1,2}] [-O {False,asc,desc}] [-A] [-f PATH_FILTER] [--set-album-thumbnail {first,last,random,random-all,random-filtered}] [--visibility {archive,hidden,locked,timeline}] 95 | [--find-archived-assets] [--read-album-properties] [--api-timeout API_TIMEOUT] [--comments-and-likes-enabled] [--comments-and-likes-disabled] [--update-album-props-mode {0,1,2}] 96 | root_path api_url api_key 97 | 98 | Create Immich Albums from an external library path based on the top level folders 99 | 100 | positional arguments: 101 | root_path The external library's root path in Immich 102 | api_url The root API URL of immich, e.g. https://immich.mydomain.com/api/ 103 | api_key The Immich API Key to use. Set --api-key-type to 'file' if a file path is provided. 104 | 105 | options: 106 | -h, --help show this help message and exit 107 | --api-key API_KEY Additional API Keys to run the script for; May be specified multiple times for running the script for multiple users. (default: None) 108 | -t {literal,file}, --api-key-type {literal,file} 109 | The type of the Immich API Key (default: literal) 110 | -r ROOT_PATH, --root-path ROOT_PATH 111 | Additional external library root path in Immich; May be specified multiple times for multiple import paths or external libraries. (default: None) 112 | -u, --unattended Do not ask for user confirmation after identifying albums. Set this flag to run script as a cronjob. (default: False) 113 | -a ALBUM_LEVELS, --album-levels ALBUM_LEVELS 114 | 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 115 | 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 . 116 | (default: 1) 117 | -s ALBUM_SEPARATOR, --album-separator ALBUM_SEPARATOR 118 | Separator string to use for compound album names created from nested folders. Only effective if -a is set to a value > 1 (default: ) 119 | -R PATTERN [REPL ...], --album-name-post-regex PATTERN [REPL ...] 120 | Regex pattern and optional replacement (use "" for empty replacement). Can be specified multiple times. (default: None) 121 | -c CHUNK_SIZE, --chunk-size CHUNK_SIZE 122 | Maximum number of assets to add to an album with a single API call (default: 2000) 123 | -C FETCH_CHUNK_SIZE, --fetch-chunk-size FETCH_CHUNK_SIZE 124 | Maximum number of assets to fetch with a single API call (default: 5000) 125 | -l {CRITICAL,ERROR,WARNING,INFO,DEBUG}, --log-level {CRITICAL,ERROR,WARNING,INFO,DEBUG} 126 | Log level to use. ATTENTION: Log level DEBUG logs API key in clear text! (default: INFO) 127 | -k, --insecure Pass to ignore SSL verification (default: False) 128 | -i IGNORE, --ignore IGNORE 129 | Use either literals or glob-like patterns to ignore assets for album name creation. This filter is evaluated after any values passed with --path-filter. May be specified multiple times. (default: None) 130 | -m {CREATE,CLEANUP,DELETE_ALL}, --mode {CREATE,CLEANUP,DELETE_ALL} 131 | Mode for the script to run with. CREATE = Create albums based on folder names and provided arguments; CLEANUP = Create album names based on current images and script arguments, but delete albums if they exist; 132 | DELETE_ALL = Delete all albums. If the mode is anything but CREATE, --unattended does not have any effect. Only performs deletion if -d/--delete-confirm option is set, otherwise only performs a dry-run. (default: 133 | CREATE) 134 | -d, --delete-confirm Confirm deletion of albums when running in mode CLEANUP or DELETE_ALL. If this flag is not set, these modes will perform a dry run only. Has no effect in mode CREATE (default: False) 135 | -x SHARE_WITH, --share-with SHARE_WITH 136 | A user name (or email address of an existing user) to share newly created albums with. Sharing only happens if the album was actually created, not if new assets were added to an existing album. If the the share 137 | role should be specified by user, the format = must be used, where must be one of 'viewer' or 'editor'. May be specified multiple times to share albums with more than one user. 138 | (default: None) 139 | -o {viewer,editor}, --share-role {viewer,editor} 140 | The default share role for users newly created albums are shared with. Only effective if --share-with is specified at least once and the share role is not specified within --share-with. (default: viewer) 141 | -S {0,1,2}, --sync-mode {0,1,2} 142 | Synchronization mode to use. Synchronization mode helps synchronizing changes in external libraries structures to Immich after albums have already been created. Possible Modes: 0 = do nothing; 1 = Delete any empty 143 | albums; 2 = Delete offline assets AND any empty albums (default: 0) 144 | -O {False,asc,desc}, --album-order {False,asc,desc} 145 | Set sorting order for newly created albums to newest or oldest file first, Immich defaults to newest file first (default: False) 146 | -A, --find-assets-in-albums 147 | By default, the script only finds assets that are not assigned to any album yet. Set this option to make the script discover assets that are already part of an album and handle them as usual. If --find-archived- 148 | assets is set as well, both options apply. (default: False) 149 | -f PATH_FILTER, --path-filter PATH_FILTER 150 | Use either literals or glob-like patterns to filter assets before album name creation. This filter is evaluated before any values passed with --ignore. May be specified multiple times. (default: None) 151 | --set-album-thumbnail {first,last,random,random-all,random-filtered} 152 | Set first/last/random image as thumbnail for newly created albums or albums assets have been added to. If set to random-filtered, thumbnails are shuffled for all albums whose assets would not be filtered out or 153 | ignored by the ignore or path-filter options, even if no assets were added during the run. If set to random-all, the thumbnails for ALL albums will be shuffled on every run. (default: None) 154 | --visibility {archive,hidden,locked,timeline} 155 | Set this option to automatically set the visibility of all assets that are discovered by the script and assigned to albums. Exception for value 'locked': Assets will not be added to any albums, but to the 'locked' folder only. Also applies if -m/--mode is set to 156 | CLEAN_UP or DELETE_ALL; then it affects all assets in the deleted albums. Always overrides -v/--archive. (default: None) 157 | --find-archived-assets 158 | By default, the script only finds assets with visibility set to 'timeline' (which is the default). Set this option to make the script discover assets with visibility 'archive' as well. If -A/--find-assets-in-albums is set as well, both options apply. (default: False) 159 | --read-album-properties 160 | If set, the script tries to access all passed root paths and recursively search for .albumprops files in all contained folders. These properties will be used to set custom options on an per-album level. Check the 161 | readme for a complete documentation. (default: False) 162 | --api-timeout API_TIMEOUT 163 | Timeout when requesting Immich API in seconds (default: 20) 164 | --comments-and-likes-enabled 165 | Pass this argument to enable comment and like functionality in all albums this script adds assets to. Cannot be used together with --comments-and-likes-disabled (default: False) 166 | --comments-and-likes-disabled 167 | Pass this argument to disable comment and like functionality in all albums this script adds assets to. Cannot be used together with --comments-and-likes-enabled (default: False) 168 | --update-album-props-mode {0,1,2} 169 | Change how album properties are updated whenever new assets are added to an album. Album properties can either come from script arguments or the .albumprops file. Possible values: 0 = Do not change album 170 | properties. 1 = Only override album properties but do not change the share status. 2 = Override album properties and share status, this will remove all users from the album which are not in the SHARE_WITH list. 171 | (default: 0) 172 | --max-retry-count MAX_RETRY_COUNT 173 | Number of times to retry an Immich API call if it timed out before failing. (default: 3) 174 | 175 | ``` 176 | 177 | __Plain example without optional arguments:__ 178 | ```bash 179 | python3 ./immich_auto_album.py \ 180 | /path/to/external/lib \ 181 | https://immich.mydomain.com/api \ 182 | thisIsMyApiKeyCopiedFromImmichWebGui 183 | ``` 184 | > [!IMPORTANT] 185 | > You must pass one root path as the first positional argument to the script. You cannot use `--root-path` alone if you only have a single root path! 186 | > Pass `--root-path` additionally for each additional root path you want to use. 187 | 188 | __Example:__ 189 | ```bash 190 | python3 ./immich_auto_album.py \ 191 | --root-path /my/second/root_path \ 192 | --root-path /my/third/root_path 193 | /my/first/root_path \ 194 | https://immich.mydomain.com/api \ 195 | thisIsMyApiKeyCopiedFromImmichWebGui 196 | ``` 197 | ### Docker 198 | 199 | A Docker image is provided to be used as a runtime environment. It can be used to either run the script manually, or via cronjob by providing a crontab expression to the container. The container can then be added to the Immich compose stack directly. 200 | The container runs rootless, by default with `uid:gid` `1000:1000`. This can be overridden in the `docker` command or `docker-compose` file. 201 | 202 | #### Environment Variables 203 | The environment variables are analogous to the script's command line arguments. 204 | 205 | | Environment variable | Mandatory? | Description | 206 | | :--------------------------- | :--------- | :---------- | 207 | | `ROOT_PATH` | yes | A single or a comma separated list of import paths for external libraries in Immich.
Refer to [Choosing the correct `root_path`](#choosing-the-correct-root_path).| 208 | | `API_URL` | yes | The root API URL of immich, e.g. https://immich.mydomain.com/api/ | 209 | | `API_KEY` | no | A colon `:` separated list of API Keys to run the script for. Either `API_KEY` or `API_KEY_FILE` must be specified. The `API_KEY` variable takes precedence for ease of manual execution, but it is recommended to use `API_KEY_FILE`. 210 | | `API_KEY_FILE` | no | A colon `:` separated list of absolute paths (from the root of the container) to files containing an Immich API Key, one key per file. The file might be mounted into the container using a volume (e.g. `-v /path/to/api_key.secret:/immich_api_key.secret:ro`). Each file must contain only the value of a single API Key.
Note that the user the container is running with must have read access to all API key file. | 211 | | `CRON_EXPRESSION` | yes | A [crontab-style expression](https://crontab.guru/) (e.g. `0 * * * *`) to perform album creation on a schedule (e.g. every hour). | 212 | | `RUN_IMMEDIATELY` | no | Set to `true` to run the script right away, after running once the script will automatically run again based on the CRON_EXPRESSION | 213 | | `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 and examples. | 214 | | `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: "` `") | 215 | | `CHUNK_SIZE` | no | Maximum number of assets to add to an album with a single API call (default: `2000`) | 216 | | `FETCH_CHUNK_SIZE` | no | Maximum number of assets to fetch with a single API call (default: `5000`) | 217 | | `LOG_LEVEL` | no | Log level to use (default: INFO), allowed values: `CRITICAL`,`ERROR`,`WARNING`,`INFO`,`DEBUG` | 218 | | `INSECURE` | no | Set to `true` to disable SSL verification for the Immich API server, useful for self-signed certificates (default: `false`), allowed values: `true`, `false` | 219 | | `IGNORE` | no | A colon `:` separated list of literals or glob-style patterns that will cause an image to be ignored if found in its path. | 220 | | `MODE` | no | Mode for the script to run with.
__`CREATE`__ = Create albums based on folder names and provided arguments
__`CLEANUP`__ = Create album names based on current images and script arguments, but delete albums if they exist
__`DELETE_ALL`__ = Delete all albums.
If the mode is anything but `CREATE`, `--unattended` does not have any effect.
(default: `CREATE`).
Refer to [Cleaning Up Albums](#cleaning-up-albums). | 221 | | `DELETE_CONFIRM` | no | Confirm deletion of albums when running in mode `CLEANUP` or `DELETE_ALL`. If this flag is not set, these modes will perform a dry run only. Has no effect in mode `CREATE` (default: `False`).
Refer to [Cleaning Up Albums](#cleaning-up-albums).| 222 | | `SHARE_WITH` | no | A single or a colon (`:`) separated list of existing user names (or email addresses of existing users) to share newly created albums with. If the the share role should be specified by user, the format = must be used, where must be one of `viewer` or `editor`. May be specified multiple times to share albums with more than one user. (default: None) Sharing only happens if an album is actually created, not if new assets are added to it.
Refer to [Automatic Album Sharing](#automatic-album-sharing).| 223 | | `SHARE_ROLE` | no | The role for users newly created albums are shared with. Only effective if `SHARE_WITH` is not empty and no explicit share role was specified for at least one user. (default: viewer), allowed values: `viewer`, `editor` | 224 | | `SYNC_MODE` | no | Synchronization mode to use. Synchronization mode helps synchronizing changes in external libraries structures to Immich after albums have already been created. Possible Modes:
`0` = do nothing
`1` = Delete any empty albums
`2` = Delete offline assets AND any empty albums
(default: `0`)
Refer to [Dealing with External Library Changes](#dealing-with-external-library-changes). | 225 | | `ALBUM_ORDER` | no | Set sorting order for newly created albums to newest (`desc`) or oldest (`asc`) file first, Immich defaults to newest file first, allowed values: `asc`, `desc` | 226 | | `FIND_ASSETS_IN_ALBUMS` | no | By default, the script only finds assets that are not assigned to any album yet. Set this option to make the script discover assets that are already part of an album and handle them as usual. If --find-archived-assets is set as well, both options apply. (default: `False`)
Refer to [Assets in Multiple Albums](#assets-in-multiple-albums). | 227 | | `PATH_FILTER` | no | A colon `:` separated list of literals or glob-style patterns to filter assets before album name creation. (default: ``)
Refer to [Filtering](#filtering). | 228 | | `SET_ALBUM_THUMBNAIL` | no | Set first/last/random image as thumbnail (based on image creation timestamp) for newly created albums or albums assets have been added to.
Allowed values: `first`,`last`,`random`,`random-filtered`,`random-all`
If set to `random-filtered`, thumbnails are shuffled for all albums whose assets would not be filtered out or ignored by the `IGNORE` or `PATH_FILTER` options, even if no assets were added during the run. If set to random-all, the thumbnails for ALL albums will be shuffled on every run. (default: `None`)
Refer to [Setting Album Thumbnails](#setting-album-thumbnails). | 229 | | `VISIBILITY` | no | Set this option to automatically set the visibility of all assets that are discovered by the script and assigned to albums.
Exception for value 'locked': Assets will not be added to any albums, but to the 'locked' folder only.
Also applies if `MODE` is set to CLEAN_UP or DELETE_ALL; then it affects all assets in the deleted albums.
Always overrides `ARCHIVE`. (default: `None`)
Refer to [Asset Visibility & Locked Folder](#asset-visibility-locked-folder). | 230 | | `FIND_ARCHIVED_ASSETS` | no | By default, the script only finds assets with visibility set to 'timeline' (which is the default). Set this option to make the script discover assets with visibility 'archive' as well. If -A/--find-assets-in-albums is set as well, both options apply. (default: `False`)
Refer to [Asset Visibility & Locked Folder](#asset-visibility--locked-folder). | 231 | | `READ_ALBUM_PROPERTIES` | no | Set to `True` to enable discovery of `.albumprops` files in root paths, allowing to set different album properties for different albums. (default: `False`)
Refer to [Setting Album-Fine Properties](#setting-album-fine-properties).
Note that the user the container is running with must to your mounted external libraries for this function to work. | 232 | | `API_TIMEOUT` | no | Timeout when requesting Immich API in seconds (default: `20`) | 233 | | `COMMENTS_AND_LIKES` | no | Set to `1` to explicitly enable Comments & Likes functionality for all albums this script adds assets to, set to `0` to disable. If not set, this setting is left alone by the script. | 234 | | `UPDATE_ALBUM_PROPS_MODE` | no | Change how album properties are updated whenever new assets are added to an album. Album properties can either come from script arguments or the `.albumprops` file. Possible values:
`0` = Do not change album properties.
`1` = Only override album properties but do not change the share status.
`2` = Override album properties and share status, this will remove all users from the album which are not in the SHARE_WITH list. | 235 | | `ALBUM_NAME_POST_REGEX1..10` | no | Up to 10 numbered environment variables `ALBUM_NAME_POST_REGEX1` to `ALBUM_NAME_POST_REGEX10` for album name post processing with regular expressions.
Refer to [Album Name Regex](#album-name-regex) | 236 | | `MAX_RETRY_COUNT` | no | Maximum number of times an API call is retried if it timed out before failing.
(default: `3`)| 237 | 238 | #### Run the container with Docker 239 | 240 | To perform a manually triggered __dry run__ (only list albums that __would__ be created), use the following command (make sure not to set the `CRON_EXPRESSION` environment variable): 241 | ```bash 242 | docker run \ 243 | -e API_URL="https://immich.mydomain.com/api/" \ 244 | -e API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ 245 | -e ROOT_PATH="/external_libs/photos" \ 246 | salvoxia/immich-folder-album-creator:latest 247 | ``` 248 | To actually create albums after performing a dry run, use the following command (setting the `UNATTENDED` environment variable): 249 | ```bash 250 | docker run \ 251 | -e UNATTENDED="1" \ 252 | -e API_URL="https://immich.mydomain.com/api/" \ 253 | -e API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ 254 | -e ROOT_PATH="/external_libs/photos" \ 255 | salvoxia/immich-folder-album-creator:latest 256 | ``` 257 | 258 | To pass the API key by secret file instead of an environment variable, pass `API_KEY_FILE` containing the path to the secret file mounted into the container, use a volume mount to mount the file and run the container with a user that has read access to the screts file: 259 | ```bash 260 | docker run \ 261 | -v "./api_key.secret:/api_key.secret:ro" 262 | -u 1001:1001 \ 263 | -e UNATTENDED="1" \ 264 | -e API_KEY_FILE="/api_key.secret" \ 265 | -e API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ 266 | -e ROOT_PATH="/external_libs/photos" \ 267 | salvoxia/immich-folder-album-creator:latest 268 | ``` 269 | 270 | To set up the container to periodically run the script, give it a name, pass the TZ variable and a valid crontab expression as environment variable. This example runs the script every hour: 271 | ```bash 272 | docker run \ 273 | --name immich-folder-album-creator \ 274 | -e TZ="Europe/Berlin" \ 275 | -e CRON_EXPRESSION="0 * * * *" \ 276 | -e API_URL="https://immich.mydomain.com/api/" \ 277 | -e API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ 278 | -e ROOT_PATH="/external_libs/photos" \ 279 | salvoxia/immich-folder-album-creator:latest 280 | ``` 281 | 282 | If your external library uses multiple import paths or you have set up multiple external libraries, you can pass multiple paths in `ROOT_PATH` by setting it to a comma separated list of paths: 283 | ```bash 284 | docker run \ 285 | --name immich-folder-album-creator \ 286 | -e TZ="Europe/Berlin" \ 287 | -e CRON_EXPRESSION="0 * * * *" \ 288 | -e API_URL="https://immich.mydomain.com/api/" \ 289 | -e API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ 290 | -e ROOT_PATH="/external_libs/photos,/external_libs/more_photos" \ 291 | salvoxia/immich-folder-album-creator:latest 292 | ``` 293 | 294 | #### Run the container with Docker-Compose 295 | 296 | Adding the container to Immich's `docker-compose.yml` file: 297 | ```yml 298 | # 299 | # WARNING: Make sure to use the docker-compose.yml of the current release: 300 | # 301 | # https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml 302 | # 303 | # The compose file on main may not be compatible with the latest release. 304 | # 305 | 306 | name: immich 307 | 308 | services: 309 | immich-server: 310 | container_name: immich_server 311 | volumes: 312 | - /path/to/my/photos:/external_libs/photos 313 | ... 314 | immich-folder-album-creator: 315 | container_name: immich_folder_album_creator 316 | image: salvoxia/immich-folder-album-creator:latest 317 | restart: unless-stopped 318 | # Use a UID/GID that has read access to the mounted API key file 319 | # and external libraries 320 | user: 1001:1001 321 | volumes: 322 | - /path/to/secret/file:/immich_api_key.secret:ro 323 | # mount needed for .albumprops to work 324 | - /path/to/my/photos:/external_libs/photos 325 | environment: 326 | API_URL: http://immich_server:2283/api 327 | API_KEY_FILE: /immich_api_key.secret 328 | ROOT_PATH: /external_libs/photos 329 | CRON_EXPRESSION: "0 * * * *" 330 | TZ: Europe/Berlin 331 | ``` 332 | 333 | This will periodically re-scan the library as per `CRON_EXPRESSION` settings and create albums (the cron script sets `UNATTENDED=1` explicitly). 334 | 335 | To perform a manually triggered __dry run__ (only list albums that __would__ be created) in an already running container, use the following command: 336 | 337 | ``` 338 | docker exec immich_folder_album_creator /bin/sh -c "/script/immich_auto_album.sh" 339 | ``` 340 | 341 | To actually create albums after performing the dry run, use the following command (setting the `UNATTENDED` environment variable): 342 | 343 | ``` 344 | docker exec immich_folder_album_creator /bin/sh -c "UNATTENDED=1 /script/immich_auto_album.sh" 345 | ``` 346 | 347 | ### Choosing the correct `root_path` 348 | The root path `/path/to/external/lib/` is the path you have mounted your external library into the Immich container. 349 | If you are following [Immich's External library Documentation](https://immich.app/docs/guides/external-library), you are using an environment variable called `${EXTERNAL_PATH}` which is mounted to `/usr/src/app/external` in the Immich container. Your `root_path` to pass to the script is `/usr/src/app/external`. 350 | 351 | ## How it works 352 | 353 | The script utilizes [Immich's REST API](https://immich.app/docs/api/) to query all images indexed by Immich, extract the folder for all images that are in the top level of any provided `root_path`, then creates albums with the names of these folders (if not yet exists) and adds the images to the correct albums. 354 | 355 | The following arguments influence what albums are created: 356 | `root_path`, `--album-levels` and `--album-separator` 357 | 358 | - `root_path` is the base path where images are looked for. Multiple root paths can be specified by adding the `-r` argument as many times as necessary. Only images within that base path will be considered for album creation. 359 | - `--album-levels` controls how many levels of nested folders are considered when creating albums. The default is `1`. For examples see below. 360 | - `--album-separator` sets the separator used for concatenating nested folder names to create an album name. It is a blank by default. 361 | 362 | __Examples:__ 363 | Suppose you provide an external library to Immich under the path `/external_libs/photos`. 364 | The folder structure of `photos` might look like this: 365 | 366 | ``` 367 | /external_libs/photos/ 368 | ├── 2020/ 369 | │ ├── 02 Feb/ 370 | │ │ └── Vacation/ 371 | │ ├── 08 Aug/ 372 | │ │ └── Vacation/ 373 | ├── Birthdays/ 374 | │ ├── John/ 375 | │ └── Jane/ 376 | └── Skiing 2023/ 377 | ``` 378 | 379 | Albums created for `root_path = /external_libs/photos` (`--album-levels` is implicitly set to `1`): 380 | - `2020` (containing all images from `2020` and all sub-folders) 381 | - `Birthdays` (containing all images from Birthdays itself as well as `John` and `Jane`) 382 | - `Skiing 2023` 383 | 384 | Albums created for `root_path = /external_libs/photos` (`--album-levels` is implicitly set to `1`) and `--ignore "Vacation"`: 385 | - `2020` (containing all images from `2020`, `2020/02 Feb` and `2020/08 Aug`, but __NOT__ `2020/02 Feb/Vacation` or `2020/08 Aug/Vacation`) 386 | - `Birthdays` (containing all images from Birthdays itself as well as `John` and `Jane`) 387 | - `Skiing 2023` 388 | 389 | Albums created for `root_path = /external_libs/photos/Birthdays`: 390 | - `John` 391 | - `Jane` 392 | 393 | Albums created for `root_path = /external_libs/photos` and `--album-levels = 2`: 394 | - `2020` (containing all images from `2020` itself, if any) 395 | - `2020 02 Feb` (containing all images from `2020/02 Feb` itself, `2020/02 Feb/Vacation`) 396 | - `2020 08 Aug` (containing all images from `2020/08 Aug` itself, `2020/08 Aug/Vacation`) 397 | - `Birthdays John` (containing all images from `Birthdays/John`) 398 | - `Birthdays Jane` (containing all images from `Birthdays/Jane`) 399 | - `Skiing 2023` 400 | 401 | Albums created for `root_path = /external_libs/photos`, `--album-levels = 3` and `--album-separator " - "` : 402 | - `2020` (containing all images from `2020` itself, if any) 403 | - `2020 - 02 Feb` (containing all images from `2020/02 Feb` itself, if any) 404 | - `2020 - 02 Feb - Vacation` (containing all images from `2020/02 Feb/Vacation`) 405 | - `2020 - 08 Aug - Vacation` (containing all images from `2020/02 Aug/Vacation`) 406 | - `Birthdays - John` 407 | - `Birthdays - Jane` 408 | - `Skiing 2023` 409 | 410 | Albums created for `root_path = /external_libs/photos`, `--album-levels = -1` and `--album-separator " - "` : 411 | - `2020` (containing all images from `2020` itself, if any) 412 | - `02 Feb` (containing all images from `2020/02 Feb` itself, if any) 413 | - `Vacation` (containing all images from `2020/02 Feb/Vacation` AND `2020/08 Aug/Vacation`) 414 | - `John` (containing all images from `Birthdays/John`) 415 | - `Jane` (containing all images from `Birthdays/Jane`) 416 | - `Skiing 2023` 417 | 418 | ## Album Level Ranges 419 | 420 | It is possible to specify not just a number for `--album-levels`, but a range from level x to level y in the folder structure that should make up an album's name: 421 | `--album-levels="2,3"` 422 | The range is applied to the folder structure beneath `root_path` from the top for positive levels and from the bottom for negative levels. 423 | Suppose the following folder structure for an external library with the script's `root_path` set to `/external_libs/photos`: 424 | ``` 425 | /external_libs/photos/2020/2020 02 Feb/Vacation 426 | /external_libs/photos/2020/2020 08 Aug/Vacation 427 | ``` 428 | - `--album-levels="2,3"` will create albums (for this folder structure, this is equal to `--album-levels="-2"`) 429 | - `2020 02 Feb Vacation` 430 | - `2020 08 Aug Vacation` 431 | - `--album-levels="2,2"` will create albums (for this folder structure, this is equal to `--album-levels="-2,-2"`) 432 | - `2020 02 Feb` 433 | - `2020 08 Aug` 434 | 435 | > [!IMPORTANT] 436 | > When passing negative ranges as album levels, you __must__ pass the argument in the form `--album-levels="-2,-2"`. Emphasis is on the equals sign `=` separating the option from the value. Otherwise, you might get an error `argument -a/--album-levels: expected one argument`! 437 | 438 | > [!WARNING] 439 | > 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). 440 | 441 | Since Immich does not support real nested albums ([yet?](https://github.com/immich-app/immich/discussions/2073)), neither does this script. 442 | 443 | ## Filtering 444 | 445 | It is possible filter images by either specifying keywords or path patterns to either specifically filter for or ignore assets based on their path. Two options control this behavior. 446 | Internally, the script converts literals to glob-patterns that will match a path if the specified literal occurs anywhere in it. Example: `--ignore Feb` is equal to `--ignore **/*Feb*/**`. 447 | 448 | The following wild-cards are supported: 449 | | Pattern | Meaning | 450 | |---------|---------------------------------------------------------------------------------------------| 451 | |`*` | Matches everything (even nothing) within one folder level | 452 | |`?` | Matches any single character | 453 | |`[]` | Matches one character in the brackets, e.g. `[a]` literally matches `a` | 454 | |`[!]` | Matches one character *not* in the brackets, e.h. `[!a]` matches any character **but** `a` | 455 | 456 | 457 | ### Ignoring Assets 458 | The option `-i / --ignore` can be specified multiple times for each literal or glob-style path pattern. 459 | When using Docker, the environment variable `IGNORE` accepts a colon-separated `:` list of literals or glob-style patterns. If an image's path **below the root path** matches the pattern, it will be ignored. 460 | 461 | ### Filtering for Assets 462 | The option `-f / ---path-filter` can be specified multiple times for each literal or glob-style path pattern. 463 | When using Docker, the environment variable `PATH_FILTER` accepts a colon-separated `:` of literals or glob-style patterns. If an image's path **below the root path** does **NOT** match the pattern, it will be ignored. 464 | 465 | > [!TIP] 466 | > When working with path filters, consider setting the `-A / --find-assets-in-albums` option or Docker environment variable `FIND_ASSETS_IN_ALBUMS` for the script to discover assets that are already part of an album. That way, assets can be added to multiple albums by the script. Refer to the [Assets in Multiple Albums](#assets-in-multiple-albums) section for more information. 467 | 468 | ### Filter Examples 469 | Consider the following folder structure: 470 | ``` 471 | /external_libs/photos/ 472 | ├── 2020/ 473 | │ ├── 02 Feb/ 474 | │ │ └── Vacation/ 475 | │ ├── 08 Aug/ 476 | │ │ └── Vacation/ 477 | ├── Birthdays/ 478 | │ ├── John/ 479 | │ └── Jane/ 480 | └── Skiing 2023/ 481 | ``` 482 | 483 | - To only create a `Birthdays` album with all images directly in `Birthdays` or in any subfolder on any level, run the script with the following options: 484 | - `root_path=/external_libs/photos` 485 | - `--album-level=1` 486 | - `--path-filter Birthdays/**` 487 | - To only create albums for the 2020s (all 202x years), but with the album names like `2020 02 Feb`, run the script with the following options: 488 | - `root_path=/external_libs/photos` 489 | - `--album-level=2` 490 | - `--path-filter=202?/**` 491 | - To only create albums for 2020s (all 202x years) with the album names like `2020 02 Feb`, but only with images in folders **one level** below `2020` and **not** any of the `Vacation` images, run the script with the following options: 492 | - `root_path=/external_libs/photos` 493 | - `--album-level=2` 494 | - `--path-filter=202?/*/*` 495 | - To create a `Vacation` album with all vacation images, run the script with the following options: 496 | - `root_path=/external_libs/photos` 497 | - `--album-level=-1` 498 | - `--path-filter=**/Vacation/*` 499 | 500 | ## Album Name Regex 501 | 502 | As a last step it is possible to run search and replace on Album Names. This can be repetitive with the following syntax: `-R PATTERN [REPLACEMENT] [-R PATTERN [REPLACEMENT]]` (equal to `--album-name-post-regex`) 503 | * PATTERN should be an regex 504 | * REPLACEMENT is optional default '' 505 | The search and replace operations are performed in the sequence the patterns and replacements are passed to the script. 506 | 507 | For Docker, these patterns are passed in numbered environment variables starting with `ALBUM_NAME_POST_REGEX1` up to `ALBUM_NAME_POST_REGEX10`. These are passed to the script in ascending order. 508 | 509 | ### Regex Examples 510 | Consider the following folder structure where you have a YYYY/MMDD, YYYY/DD MMM or similar structure: 511 | ``` 512 | /external_libs/photos/ 513 | └── 2020/ 514 | └── 02 Feb My Birthday 515 | └── 0408_Cycling_Holidays_in_the_Alps 516 | ``` 517 | 518 | In a default way, the script would create Album as `2020 02 Feb My Birthday` and `2020 0408_Cycling_Holidays_in_the_Alps`. 519 | As we see, the album names get pretty long and as Immich extracts EXIF dates, there is no need for these structed dates in album name. Furthermore, the underscores may be good for file operations but don't look nice in our album names. Cleaning up the album names can be accomplished with two regular expressions in sequence: 520 | 521 | ```bash 522 | python3 immich_auto_album.py /mnt/library http://localhost:2283/api \ 523 | --album-levels 2 \ 524 | --album-separator '' \ 525 | --album-name-post-regex '[\d]+_|\d+\s\w{3}' \ 526 | --album-name-post-regex '_' ' ' 527 | ``` 528 | The first pattern only specifies a regular expression and no replacement, which means any matching string will effectively be removed from the album name. 529 | The second pattern specifies to replace underscores `_` with a blank ` `. 530 | As a result, the album names will be `Cycling holidays in the Alps` and `My Birthday`. 531 | 532 | >[!IMPORTANT] 533 | >When using this feature with Docker, the regular expressions need to retain the single quotes `'`. 534 | >In `docker-compose`, backslashes must be escaped as well! 535 | 536 | Example when running from command line: 537 | ```bash 538 | docker run \ 539 | -e ROOT_PATH="/external_libs/photos" \ 540 | -e API_URL=" http://localhost:2283/api" \ 541 | -e API_KEY="" \ 542 | -e ALBUM_LEVELS="2" \ 543 | -e ALBUM_SEPARATOR="" \ 544 | -e ALBUM_NAME_POST_REGEX1="'[\d]+_|\d+\s\w{3}'" \ 545 | -e ALBUM_NAME_POST_REGEX2="'_' ' '" 546 | ``` 547 | 548 | Example when using `docker-compose`: 549 | ```yaml 550 | --- 551 | services: 552 | immich-folder-album-creator: 553 | container_name: immich_folder_album_creator 554 | image: salvoxia/immich-folder-album-creator:latest 555 | restart: unless-stopped 556 | environment: 557 | API_URL: http://immich_server:2283/api 558 | API_KEY: 559 | ROOT_PATH: /external_libs/photos 560 | ALBUM_LEVELS: 2 561 | ALBUM_SEPARATOR: "" 562 | # backslashes must be escaped in YAML 563 | ALBUM_NAME_POST_REGEX1: "'[\\d]+_|\\d+\\s\\w{3}'" 564 | ALBUM_NAME_POST_REGEX2: "'_' ' '" 565 | LOG_LEVEL: DEBUG 566 | CRON_EXPRESSION: "0 * * * *" 567 | TZ: Europe/Berlin 568 | ``` 569 | 570 | 571 | ## Automatic Album Sharing 572 | 573 | The scripts support sharing newly created albums with a list of existing users. The sharing role (`viewer` or `editor`) can be specified for all users at once or individually per user. 574 | 575 | ### Album Sharing Examples (Bare Python Script) 576 | Two arguments control this feature: 577 | - `-o / --share-role`: The default role for users an album is shared with. Allowed values are `viewer` or `editor`. This argument is optional and defaults to `viewer`. 578 | - `-x / --share-with`: Specify once per user to share with. The value should be either the user name or the user's email address as specified in Immich. If the user name is used and it contains blanks ` `, it must be wrapped in double quotes `"`. To override the default share role and specify a role explicitly for this user, the format `=` must be used (refer to examples below). 579 | 580 | To share new albums with users `User A` and `User B` as `viewer`, use the following call: 581 | ```bash 582 | python3 ./immich_auto_album.py \ 583 | --share-with "User A" \ 584 | --share-with "User B" \ 585 | /path/to/external/lib \ 586 | https://immich.mydomain.com/api \ 587 | thisIsMyApiKeyCopiedFromImmichWebGui 588 | ``` 589 | 590 | To share new albums with users `User A` and `User B` as `editor`, use the following call: 591 | ```bash 592 | python3 ./immich_auto_album.py \ 593 | --share-with "User A" \ 594 | --share-with "User B" \ 595 | --share-role "editor" \ 596 | /path/to/external/lib \ 597 | https://immich.mydomain.com/api \ 598 | thisIsMyApiKeyCopiedFromImmichWebGui 599 | ``` 600 | 601 | To share new albums with users `User A` and a user with mail address `userB@mydomain.com`, but `User A` should be an editor, use the following call: 602 | ```bash 603 | python3 ./immich_auto_album.py \ 604 | --share-with "User A=editor" \ 605 | --share-with "userB@mydomain.com" \ 606 | path/to/external/lib \ 607 | https://immich.mydomain.com/api \ 608 | thisIs 609 | ``` 610 | 611 | Per default these share settings are applied once when the album is created and remain unchanged if an asset is added to an album later. If you want to override the share state whenever an asset is added to an album you can set `--update-album-props-mode` to `2`. Note that this will completely override all shared users, any changes made within Immich will be lost. 612 | 613 | ### Album Sharing Examples (Docker) 614 | Two environment variables control this feature: 615 | - `SHARE_ROLE`: The default role for users an album is shared with. Allowed values are `viewer` or `editor`. This argument is optional and defaults to `viewer`. 616 | - `SHARE_WITH`: A colon `:` separated list of either names or email addresses (or a mix) of existing users. To override the default share role and specify a role explicitly for each user, the format `=` must be used (refer to examples below). 617 | 618 | To share new albums with users `User A` and `User B` as `viewer`, use the following call: 619 | ```bash 620 | docker run \ 621 | -e SHARE_WITH="User A:User B" \ 622 | -e UNATTENDED="1" \ 623 | -e API_URL="https://immich.mydomain.com/api/" \ 624 | -e API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ 625 | -e ROOT_PATH="/external_libs/photos" \ 626 | salvoxia/immich-folder-album-creator:latest \ 627 | /script/immich_auto_album.sh 628 | ``` 629 | 630 | To share new albums with users `User A` and `User B` as `editor`, use the following call: 631 | ```bash 632 | docker run \ 633 | -e SHARE_WITH="User A:User B" \ 634 | -e SHARE_ROLE="editor" \ 635 | -e UNATTENDED="1" \ 636 | -e API_URL="https://immich.mydomain.com/api/" \ 637 | -e API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ 638 | -e ROOT_PATH="/external_libs/photos" \ 639 | salvoxia/immich-folder-album-creator:latest \ 640 | /script/immich_auto_album.sh 641 | ``` 642 | 643 | To share new albums with users `User A` and a user with mail address `userB@mydomain.com`, but `User A` should be an editor, use the following call: 644 | ```bash 645 | docker run \ 646 | -e SHARE_WITH="User A=editor:userB@mydomain.com" \ 647 | -e UNATTENDED="1" \ 648 | -e API_URL="https://immich.mydomain.com/api/" \ 649 | -e API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ 650 | -e ROOT_PATH="/external_libs/photos" \ 651 | salvoxia/immich-folder-album-creator:latest \ 652 | /script/immich_auto_album.sh 653 | ``` 654 | 655 | Per default these share settings are applied once when the album is created and remain unchanged if an asset is added to an album later. If you want to override the share state whenever an asset is added to an album you can set `UPDATE_ALBUM_PROPS_MODE` to `2`. Note that this will completely override all shared users, any changes made within Immich will be lost. 656 | 657 | 658 | ## Cleaning Up Albums 659 | 660 | The script supports different run modes (option `-m`/`--mode` or env variable `MODE` for Docker). The default mode is `CREATE`, which is used to create albums. 661 | The other two modes are `CLEANUP` and `DELETE_ALL`. 662 | > [!CAUTION] 663 | > Regardless of the mode you are using, deleting albums cannot be undone! The only option is to let the script run again and create new albums base on the passed arguments and current assets in Immich. 664 | 665 | To prevent accidental deletions, setting the mode to `CLEANUP` or `DELETE_ALL` alone will not actually delete any albums, but only perform a dry run. The dry run prints a list of albums that the script __would__ delete. 666 | To actually delete albums, the option `-d/--delete-confirm` (or env variable `DELETE_CONFIRM` for Docker) must be set. 667 | 668 | ### `CLEANUP` 669 | The script will generate album names using the script's arguments and the assets found in Immich, but instead of creating the albums, it will delete them (if they exist). This is useful if a large number of albums was created with no/the wrong `--album-separator` or `--album-levels` settings. 670 | 671 | ### `DELETE_ALL` 672 | > [!CAUTION] 673 | > As the name suggests, this mode blindly deletes **ALL** albums from Immich. Use with caution! 674 | 675 | 676 | ## Assets in Multiple Albums 677 | 678 | By default, the script only fetches assets from Immich that are not assigned to any album yet. This makes querying assets in large libraries very fast. However, if assets should be part of either manually created albums as well as albums based on the folder structure, or if multiple script passes with different album level settings should create differently named albums with overlapping contents, the option `--find-assets-in-albums` (bare Python) or environment variable `FIND_ASSETS_IN_ALBUMS` (Docker) may be set. 679 | In that case, the script will request all assets from Immich and add them to their corresponding folders, even if the also are part of other albums. 680 | > [!TIP] 681 | > This option can be especially useful when [Filtering for Assets](#filtering-for-assets). 682 | 683 | 684 | ## Setting Album Thumbnails 685 | 686 | The script supports automatically setting album thumbnails by specifying the `--set-album-thumbnail` option (bare Python) or `SET_ALBUM_THUMBNAIL` environment variable (Docker). There are several options to choose from for thumbnail selection: 687 | - `first`: Sets the first image as thumbnail based on image creation timestamps 688 | - `last`: Sets the last image as thumbnail based on image creation timestamps 689 | - `random`: Sets the thumbnail to a random image 690 | 691 | When using one of the values above, the thumbnail of an album will be updated whenever assets are added. 692 | 693 | Furthermore, the script supports two additional modes that are applied __even if no assets were added to the album__: 694 | - `random-all`: In this mode the thumbnail for __all albums__ will be shuffled every time the script runs, ignoring any `root_path`, `--ignore` or `--path-filter` values. 695 | - `random-filtered`: Using this mode, the thumbnail for an albums will be shuffled every run if the album is not ignored by `root_path` or due to usage of the `--ignore` or `--path-filter` options. 696 | 697 | > [!CAUTION] 698 | > Updating album thumbnails cannot be reverted! 699 | 700 | ## Setting Album-Fine Properties 701 | 702 | This script supports defining album properties on a per-folder basis. For example, it is possible to share albums with different users using different roles or disable comments and likes for different albums. For a full list of options see the example below. 703 | This is achieved by placing `.albumprops` files in each folder these properties should apply to later. The script will scan for all `.albumprops` files and apply the settings when creating albums or adding assets to existing albums. 704 | 705 | ### Prerequisites 706 | 707 | This function requires that all `root_paths` passed to the script are also accessible to it on a file-system level. 708 | - Docker: All root paths must be mounted under the same path to the `immich-folder-album-creator` container as they are mounted to the Immich container 709 | - Docker: The container must run with a user/group ID that has read access to the mounted root paths to be able to discover and read the `.albumprops` files 710 | - Bare Python Script: The script must have access to all root paths under the same path as they are mounted into the Immich container 711 | Either mount the folders into Immich under the same path as they are on the Immich host, or create symlinks for the script to access 712 | 713 | ### `.albumprops` File Format 714 | 715 | A file named `.albumprops` may be placed into any folder of an external library. 716 | The file itself is a YAML formatted text file with the following properties: 717 | ```yaml 718 | # Album Name overriding the album name generated from folder names 719 | override_name: "Your images are in another album" 720 | description: "This is a very informative text describing the album" 721 | share_with: 722 | # either provide user name 723 | - user: "user1" 724 | role: "editor" 725 | # or provide user mail address 726 | - user: "user2@example.org" 727 | role: "viewer" 728 | # role is optional and defaults to "viewer" if not specified 729 | - user: "user3@example.org" 730 | # Special role "none" can be used to remove inherited users 731 | - user: "user4" 732 | role: "none" 733 | # Set album thumbnail, valid values: first, last, random or fully qualified path of an asset that is (or will be) assigned to the album 734 | thumbnail_setting: "first" 735 | # Sort order in album, valid values: asc, desc 736 | sort_order: "desc" 737 | # Set the visibility of assets that are getting added to that album, valid values: archive, hidden, locked, timeline 738 | visibility: 'timeline' 739 | # Flag indicating whether assets in this albums can be commented on and liked 740 | comments_and_likes_enabled: false 741 | # Flag indicating whether properties should be inherited down the directory tree 742 | inherit: true 743 | # List of property names that should be inherited (if not specified, all properties are inherited) 744 | inherit_properties: 745 | - "description" 746 | - "share_with" 747 | - "visibility" 748 | ``` 749 | All properties are optional. 750 | The scripts expects the file to be in UTF-8 encoding. 751 | 752 | #### Property Inheritance 753 | 754 | The script supports property inheritance from parent folders through the `inherit` and `inherit_properties` settings: 755 | 756 | - **`inherit: true`**: Enables inheritance of properties from parent `.albumprops` files 757 | - **`inherit_properties`**: Specifies which properties to inherit (if omitted, all properties are inherited) 758 | 759 | ##### Inheritance Rules 760 | 761 | 1. **Inheritance Chain**: Properties are inherited from the root path down to the current folder 762 | 2. **Property Precedence**: Properties in deeper folders override those in parent folders 763 | 3. **Inheritance Termination**: If a folder has `inherit: false` or no `inherit` property, while having a `.albumprops`.file, the inheritance chain stops at that folder 764 | 765 | ##### Special `share_with` Inheritance 766 | 767 | The `share_with` property has special inheritance behavior: 768 | - **Addition**: Users from parent folders are automatically included 769 | - **Modification**: User roles can be changed by specifying the same user with a different role 770 | - **Removal**: Users can be removed by setting their role to `"none"` 771 | 772 | ##### Album Merging Behavior 773 | 774 | When multiple directories use the same `override_name` and contribute to a single album, the following rules apply: 775 | 776 | 1. **Most Restrictive Role Wins**: If the same user is specified with different roles across multiple directories, the most restrictive role is applied: 777 | - `viewer` is more restrictive than `editor` 778 | - Example: User specified as `editor` in one directory and `viewer` in another → final role is `viewer` 779 | 780 | 2. **User Removal is Permanent**: If a user is set to `role: "none"` in any directory contributing to the album, they cannot be re-added by other directories: 781 | - Once removed with `role: "none"`, the user is permanently excluded from that album 782 | - Subsequent attempts to add the same user with any role will be ignored 783 | 784 | 3. **User Accumulation**: Users from all contributing directories are combined, following the above precedence rules 785 | 786 | This ensures consistent and predictable behavior when multiple folder structures contribute to the same album via `override_name`. 787 | 788 | 789 | ##### Inheritance Examples 790 | 791 | **Example 1: Basic Inheritance** 792 | 793 | `/photos/.albumprops`: 794 | ```yaml 795 | inherit: true 796 | description: "Family photos" 797 | share_with: 798 | - user: "dad" 799 | role: "editor" 800 | ``` 801 | 802 | `/photos/2023/.albumprops`: 803 | ```yaml 804 | inherit: true 805 | share_with: 806 | - user: "mom" 807 | role: "viewer" 808 | ``` 809 | 810 | Result for `/photos/2023/vacation/`: 811 | ```yaml 812 | # - description: "Family photos" (inherited) 813 | # - share_with: dad (editor), mom (viewer) 814 | ``` 815 | 816 | **Example 2: Property Override and User Management** 817 | 818 | `/photos/.albumprops`: 819 | ```yaml 820 | inherit: true 821 | description: "Family photos" 822 | visibility: "timeline" 823 | share_with: 824 | - user: "dad" 825 | role: "editor" 826 | - user: "mom" 827 | role: "viewer" 828 | ``` 829 | 830 | `/photos/private/.albumprops`: 831 | ```yaml 832 | inherit: true 833 | inherit_properties: ["description"] # Only inherit description 834 | visibility: "archive" # Override visibility 835 | share_with: 836 | - user: "mom" 837 | role: "none" # Remove mom from sharing 838 | - user: "admin" 839 | role: "editor" # Add admin 840 | ``` 841 | 842 | Result for `/photos/private/secrets/`: 843 | ```yaml 844 | # - description: "Family photos" (inherited) 845 | # - visibility: "archive" (overridden, not inherited due to inherit_properties) 846 | # - share_with: dad (editor, inherited), admin (editor, added) 847 | # - mom is removed from sharing 848 | ``` 849 | 850 | **Example 3: Stopping Inheritance** 851 | 852 | `/photos/.albumprops`: 853 | ```yaml 854 | inherit: true 855 | description: "Family photos" 856 | share_with: 857 | - user: "family" 858 | role: "viewer" 859 | ``` 860 | 861 | `/photos/work/.albumprops`: 862 | ```yaml 863 | inherit: false # Stop inheritance 864 | description: "Work photos" 865 | share_with: 866 | - user: "colleague" 867 | role: "editor" 868 | ``` 869 | 870 | Result for `/photos/work/project/`: 871 | ```yaml 872 | # - description: "Work photos" (from /photos/work/, no inheritance) 873 | # - share_with: colleague (editor, no family member inherited) 874 | ``` 875 | 876 | **Example 4: Album Merging with `override_name`** 877 | 878 | `/photos/2023/Christmas/.albumprops`: 879 | ```yaml 880 | override_name: "Family Photos" 881 | description: "Family photos" 882 | inherit: true 883 | share_with: 884 | - user: "dad" 885 | role: "editor" 886 | ``` 887 | 888 | `/photos/2023/Christmas/Cookies/.albumprops`: 889 | ```yaml 890 | inherit: true 891 | share_with: 892 | - user: "mom" 893 | role: "viewer" 894 | ``` 895 | 896 | `/photos/2023/Vacation/.albumprops`: 897 | ```yaml 898 | override_name: "Family Photos" 899 | description: "Family photos" 900 | share_with: 901 | - user: "dad" 902 | role: "viewer" # More restrictive than editor 903 | ``` 904 | 905 | Result: Single album "Family Photos" containing all photos from all three directories: 906 | ```yaml 907 | # - name: "Family Photos" (from override_name) 908 | # - description: "Family photos" (inherited/specified) 909 | # - share_with: dad (viewer - most restrictive wins), mom (viewer) 910 | ``` 911 | 912 | **Example 5: User Removal with `role: "none"`** 913 | 914 | `/photos/family/.albumprops`: 915 | ```yaml 916 | override_name: "Shared Album" 917 | share_with: 918 | - user: "dad" 919 | role: "editor" 920 | - user: "mom" 921 | role: "viewer" 922 | - user: "child" 923 | role: "viewer" 924 | ``` 925 | 926 | `/photos/family/private/.albumprops`: 927 | ```yaml 928 | override_name: "Shared Album" # Same album name 929 | share_with: 930 | - user: "child" 931 | role: "none" # Remove child from sharing 932 | - user: "grandpa" 933 | role: "editor" 934 | ``` 935 | 936 | `/photos/family/work/.albumprops`: 937 | ```yaml 938 | override_name: "Shared Album" # Same album name 939 | share_with: 940 | - user: "child" 941 | role: "viewer" # This will be ignored - child was set to "none" 942 | - user: "colleague" 943 | role: "viewer" 944 | ``` 945 | 946 | Result: Single album "Shared Album" containing photos from all directories: 947 | ```yaml 948 | # - name: "Shared Album" 949 | # - share_with: dad (editor), mom (viewer), grandpa (editor), colleague (viewer) 950 | # - child is permanently removed and cannot be re-added 951 | ``` 952 | 953 | >[!IMPORTANT] 954 | >The `override_name` property makes it possible assign assets to an album that does not have anything to do with their folder name. That way, it is also possible to merge assets from different folders (even under different `root_paths`) into the same album. 955 | >If the script finds multiple `.albumprops` files using the same `override_name` property, it enforced that all properties that exist in at least one of the `.albumprops` files are identical in all files that use the same `override_name`. If this is not the case, the script will exit with an error. 956 | 957 | >[!TIP] 958 | > Note the possibility to set `thumbnail_setting` to an absolute asset path. This asset must be part of the album once the script has run for Immich to accept it as album thumbnail / cover. This is only possible in `.albumprops` files, as such a setting would not make much sense as a global option. 959 | 960 | ### Enabling `.albumprops` discovery 961 | 962 | To enable Album-Fine Properties, pass the option `--read-album-properties` (Bare Python) or set the environment variable `READ_ALBUM_PROPERTIES` to `1` (Docker) to enable scanning for `.albumprops` files and use the values found there to created the albums. 963 | 964 | ### Property Precedence 965 | 966 | In case the script is provided with `--share-with`, `--share-role`, `--archive`, `--set-album-thumbnail` options (or `SHARE_WITH`, `SHARE_ROLE`, `ARCHIVE`, or `SET_ALBUM_THUMBNAIL` environment variables for Docker), properties in `.albumprops` always take precedence. Options passed to the script only have effect if no `.albumprops` file is found for an album or the specific property is missing. 967 | 968 | Example: 969 | ```yaml 970 | share_with: 971 | - user: Dad 972 | role: editor 973 | ``` 974 | If the script is called with `--share-with "Mom"` and `--archive`, the album created from the folder the file above resides in will only be shared with user `Dad` using `editor` permissions, and assets will be archived. All other albums will be shared with user `Mom` (using `viewer` permissions, as defined by default) and assets will be archived. 975 | 976 | ### Example: Always add files in a specific folder to Immich Locked Folder 977 | 978 | In order to always add files incoming to a specific external library folder to Immich's Locked Folder, add the following `.albumprops` file to that folder: 979 | ```yaml 980 | visibility: 'locked' 981 | ``` 982 | 983 | ## Mass Updating Album Properties 984 | 985 | The script supports updating album properties after the fact, i.e. after they already have been created. Useful examples for this are mass sharing albums or enabling/disabling the "Comments and Likes" functionality. All album properties supported by `.albumprops` files (Refer to [Setting Album-Fine Properties](#setting-album-fine-properties)) are supported. They can be provided either by placing an `.albumprops` file in each folder, or by passing the appropriate argument to the script. 986 | Updating already existing albums is done by setting the `--find-assets-in-albums` argument (or appropriate [environment variable](#environment-variables)) to discover assets that are already assigned to albums, and also setting the `--update-album-props-mode` argument ((or appropriate [environment variable](#environment-variables))). 987 | When setting `--update-album-props-mode` to `1`, all album properties __except__ the shared status are updated. When setting it to `2`, the shared status is updated as well. 988 | By applying `--path-filter` and/or `--ignore` options, it is possible to get a more fine granular control over the albums to update. 989 | 990 | >[!IMPORTANT] 991 | > The shared status is always updated to match exactly the users and roles provided to the script, the changes are not additive. 992 | 993 | ### Examples: 994 | 1. Share all albums (either existing or newly ) created from a `Birthdays` folder with users `User A` and `User B`: 995 | ```bash 996 | python3 ./immich_auto_album.py \ 997 | --find-assets-in-albums \ 998 | --update-album-props-mode 2 \ 999 | --share-with "User A" \ 1000 | --share-with "User B" \ 1001 | --path-filter "Birthdays/**" \ 1002 | /path/to/external/lib \ 1003 | https://immich.mydomain.com/api \ 1004 | thisIsMyApiKeyCopiedFromImmichWebGui 1005 | ``` 1006 | 1007 | To unshare the same albums simply run the same command without the `--share-with` arguments. The script will make sure all identified albums are shared with all people passed in `--share-with`, that is no-one. 1008 | ```bash 1009 | python3 ./immich_auto_album.py \ 1010 | --find-assets-in-albums \ 1011 | --update-album-props-mode 2 \ 1012 | --path-filter "Birthdays/**" \ 1013 | /path/to/external/lib \ 1014 | https://immich.mydomain.com/api \ 1015 | thisIsMyApiKeyCopiedFromImmichWebGui 1016 | ``` 1017 | 1018 | 2. Disable comments and likes in all albums but the ones created from a `Birthdays` folder, without changing the "shared with" settings: 1019 | ```bash 1020 | python3 ./immich_auto_album.py \ 1021 | --find-assets-in-albums \ 1022 | --update-album-props-mode 1 \ 1023 | --disable-comments-an-likes \ 1024 | --ignore "Birthdays/**" \ 1025 | /path/to/external/lib \ 1026 | https://immich.mydomain.com/api \ 1027 | thisIsMyApiKeyCopiedFromImmichWebGui 1028 | ``` 1029 | 1030 | 1031 | ## Asset Visibility & Locked Folder 1032 | 1033 | In Immich, may be 'archived' meaning they are hidden from the main timeline and only show up in their respective albums and the 'Archive' in the sidebar menu. Immich v1.133.0 also introduced the concept of a locked folder. The user must enter a PIN code to access the contents of that locked folder. Assets that are moved to the locked folder cannot be part of any albums and naturally are not displayed in the timeline. 1034 | 1035 | This script supports both concepts with the option/environment variable `--visibility`/`VISIBILITY`. Allowed values are: 1036 | - `archive`: Assets are archived after getting added to an album 1037 | - `locked`: No albums get created, but all discovered assets after filtering are moved to the locked folder 1038 | - `timeline`: All assets are shown in the timeline after getting added to an album 1039 | 1040 | Visibility may be on an per-album basis using [Album Properties](#setting-album-fine-properties). 1041 | >[!IMPORTANT] 1042 | >Archiving images has the side effect that they are no longer detected by the script with default options. This means that if an album that was created with the `--archive` option set is deleted from the Immich user interface, the script will no longer find the images even though they are no longer assigned to an album. 1043 | To make the script find also archived images, run the script with the option `--find-archived-assets` or Docker environment variable `FIND_ARCHIVED_ASSETS=true`. 1044 | 1045 | By combining `--find-archived-assets`/`FIND_ARCHIVED_ASSETS=true` with `--visibility timeline`/`VISIBILITY timeline`, archived assets can be 'un-archived'. 1046 | 1047 | >[!WARNING] 1048 | >If the script is used to delete albums using `--mode=CLEANUP` or `--mode=DELETE_ALL` with the `--archive` option set, the script will not respect [album-fine properties](#setting-album-fine-properties) for visibility but only the global option passed when running it in that mode! That way you can decide what visibility to set for assets after their albums have been deleted. 1049 | 1050 | ### Locked Folder Considerations 1051 | When setting `--visibility`/`VISIBILITY` to `locked`, the script will move all discovered assets to the Locked Folder, removing them from any albums they might already be part of. The affected assets are determined by the following options/environment variables: 1052 | - `--find-archived-assets`/`FIND_ARCHIVED_ASSETS` 1053 | - `--find-assets-in-albums`/`FIND_ASSETS_IN_ALBUMS` 1054 | - `--ignore`/`IGNORE` 1055 | - `--path-filter`/`PATH_FILTER` 1056 | 1057 | > [!CAUTION] 1058 | > When running with `--find-assets-in-albums`/`FIND_ASSETS_IN_ALBUMS` and `--visibility`/`VISIBILITY` set to `locked`, the script will move all assets for matching albums to the locked folder, leaving empty albums behind. 1059 | When also running with `--sync-mode`/`SYNC_MODE` set to `1` or `2`, those empty albums will be deleted after that as well! 1060 | 1061 | Removing assets from the locked folder and making it available to the script again must be done using the Immich User Interface. 1062 | 1063 | 1064 | ## Dealing with External Library Changes 1065 | 1066 | Due to their nature, external libraries may be changed by the user without Immich having any say in it. 1067 | Two examples of this are the user deleting or renaming folders in their external libraries after the script has created albums, or the user moving single files from one folder to another. The script would create new albums from renamed folders or add images to their new album after they have been moved. 1068 | Immich itself deals with this by marking images/videos it no longer sees in their original location as "offline". This can lead to albums being completely populated by "offline" files only (if the folder was renamed or deleted) while they exist normally in a new album or with single images being offline in one album, while existing normally in their new albums. 1069 | As of version 1.116.0, Immich no longer shows "offline" assets in the main timeline, but only in the Trash, together with deleted assets. If the trash is emptied, Immich forgets about these "offline" assets. If the asset is available again, it is removed from the trash and shows up as normal in the main timeline. 1070 | 1071 | This script offers two levels of synchronization options to deal with these issues with an option called `Sync Mode`. It is an optional argument / environment variable that may have values from 0 to 2. 1072 | The following behaviors wil be triggered by the different values: 1073 | - `0`: No syncing (default) 1074 | - `1`: Delete all empty albums at the end of a run 1075 | - `2`: Delete ("forget") all offline assets, then delete all empty albums 1076 | 1077 | Option `1` leaves it up to the user to clear up "offline" assets by emptying the trash or restoring access to the files. Only if after any of these actions empty albums are left behind, they are automatically removed. 1078 | Option `2` will first delete all "offline" assets automatically, then do the same with any empty albums left. 1079 | 1080 | > [!IMPORTANT] 1081 | > For Immich v1.116.0 - v1.127.x finding offline assets has been broken. Immich fixed the issue with v1.128.0. 1082 | 1083 | > [!IMPORTANT] 1084 | > If your library is on a network share or external drive that might be prone to not being available all the time, avoid using `Sync Mode = 2`. 1085 | 1086 | > [!CAUTION] 1087 | > It is __not__ possible for the script to distinguish between an album that was left behind empty after Offline Asset Removal and a manually created album with no images added to it! All empty albums of that user will be deleted! 1088 | 1089 | It is up to you whether you want to use the full capabilities Sync Mode offers, parts of it or none. 1090 | An example for the Immich `docker-compose.yml` stack when using full Sync Mode might look like this: 1091 | 1092 | ### `docker-compose` example passing the API key as environment variable 1093 | ```yml 1094 | # 1095 | # WARNING: Make sure to use the docker-compose.yml of the current release: 1096 | # 1097 | # https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml 1098 | # 1099 | # The compose file on main may not be compatible with the latest release. 1100 | # 1101 | 1102 | name: immich 1103 | 1104 | services: 1105 | immich-server: 1106 | container_name: immich_server 1107 | volumes: 1108 | - /path/to/my/photos:/external_libs/photos 1109 | ... 1110 | immich-folder-album-creator: 1111 | container_name: immich_folder_album_creator 1112 | image: salvoxia/immich-folder-album-creator:latest 1113 | restart: unless-stopped 1114 | environment: 1115 | API_URL: http://immich_server:2283/api 1116 | API_KEY: "This_Is_My_API_Key_Generated_In_Immich" 1117 | ROOT_PATH: /external_libs/photos 1118 | # Run every full hour 1119 | CRON_EXPRESSION: "0 * * * *" 1120 | TZ: Europe/Berlin 1121 | # Remove offline assets and delete empty albums after each run 1122 | SYNC_MODE: "2" 1123 | ``` 1124 | 1125 | ### `docker-compose` example using a secrets file for the API key 1126 | ```yml 1127 | # 1128 | # WARNING: Make sure to use the docker-compose.yml of the current release: 1129 | # 1130 | # https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml 1131 | # 1132 | # The compose file on main may not be compatible with the latest release. 1133 | # 1134 | 1135 | name: immich 1136 | 1137 | services: 1138 | immich-server: 1139 | container_name: immich_server 1140 | volumes: 1141 | - /path/to/my/photos:/external_libs/photos 1142 | ... 1143 | immich-folder-album-creator: 1144 | container_name: immich_folder_album_creator 1145 | image: salvoxia/immich-folder-album-creator:latest 1146 | restart: unless-stopped 1147 | # Use a UID/GID that has read access to the mounted API key file 1148 | user: 1001:1001 1149 | volumes: 1150 | - /path/to/secret/file:/immich_api_key.secret:ro 1151 | environment: 1152 | API_URL: http://immich_server:2283/api 1153 | API_KEY_FILE: "/immich_api_key.secret" 1154 | ROOT_PATH: /external_libs/photos 1155 | # Run every full hour 1156 | CRON_EXPRESSION: "0 * * * *" 1157 | TZ: Europe/Berlin 1158 | # Remove offline assets and delete empty albums after each run 1159 | SYNC_MODE: "2" 1160 | ``` 1161 | -------------------------------------------------------------------------------- /immich_auto_album.py: -------------------------------------------------------------------------------- 1 | """Python script for creating albums in Immich from folder names in an external library.""" 2 | 3 | # pylint: disable=too-many-lines 4 | from __future__ import annotations 5 | import warnings 6 | from typing import Tuple 7 | import argparse 8 | import logging 9 | import sys 10 | import fnmatch 11 | import os 12 | import datetime 13 | from collections import OrderedDict 14 | import random 15 | from urllib.error import HTTPError 16 | import traceback 17 | 18 | import regex 19 | import yaml 20 | 21 | import urllib3 22 | import requests 23 | 24 | 25 | # Script Constants 26 | # Environment variable to check if the script is running inside Docker 27 | ENV_IS_DOCKER = "IS_DOCKER" 28 | 29 | # pylint: disable=R0902,R0904 30 | class ApiClient: 31 | """Encapsulates Immich API Calls in a client object""" 32 | 33 | # Default value for the maximum number of assets to add to an album in a single API call 34 | CHUNK_SIZE_DEFAULT = 2000 35 | # Maximum number of assets that can be fetched in a single API call using the search endpoint 36 | FETCH_CHUNK_SIZE_MAX = 1000 37 | # Default value for the maximum number of assets to fetch in a single API call 38 | FETCH_CHUNK_SIZE_DEFAULT = 1000 39 | # Default value for API request timeout ins econds 40 | API_TIMEOUT_DEFAULT = 20 41 | # Number of times to retry an API call if it times out 42 | MAX_RETRY_COUNT_ON_TIMEOUT_DEFAULT = 3 43 | 44 | # List of allowed share user roles 45 | SHARE_ROLES = ["editor", "viewer"] 46 | 47 | def __init__(self, api_url : str, api_key : str, **kwargs: dict): 48 | """ 49 | :param api_url: The Immich Server's API base URL 50 | :param api_key: The Immich API key to use for authentication 51 | :param **kwargs: keyword arguments allowing the following keywords: 52 | 53 | - `chunk_size: int` The number of assets to add to an album with a single API call 54 | - `fetch_chunk_size: int` The number of assets to fetch with a single API call 55 | - `api_timeout: int` The timeout to use for API calls in seconds 56 | - `insecure: bool` Flag indicating whether to skip SSL certificate validation 57 | - `max_retry_count: int` The maximum number of times to retry an API call if it timed out before failing 58 | :raises AssertionError: When validation of options failed 59 | """ 60 | # The Immich API URL to connect to 61 | self.api_url = api_url 62 | # The API key to use 63 | self.api_key = api_key 64 | 65 | self.chunk_size : int = Utils.get_value_or_config_default('chunk_size', kwargs, Configuration.CONFIG_DEFAULTS['chunk_size']) 66 | self.fetch_chunk_size : int = Utils.get_value_or_config_default('fetch_chunk_size', kwargs, Configuration.CONFIG_DEFAULTS['fetch_chunk_size']) 67 | self.api_timeout : int = Utils.get_value_or_config_default('api_timeout', kwargs, Configuration.CONFIG_DEFAULTS['api_timeout']) 68 | self.insecure : bool = Utils.get_value_or_config_default('insecure', kwargs, Configuration.CONFIG_DEFAULTS['insecure']) 69 | self.max_retry_count : int = Utils.get_value_or_config_default('max_retry_count', kwargs, Configuration.CONFIG_DEFAULTS['max_retry_count']) 70 | 71 | self.__validate_config() 72 | 73 | # Build request arguments to use for API calls 74 | self.request_args = { 75 | 'headers' : { 76 | 'x-api-key': self.api_key, 77 | 'Content-Type': 'application/json', 78 | 'Accept': 'application/json' 79 | }, 80 | 'verify' : not self.insecure 81 | } 82 | 83 | if self.insecure: 84 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 85 | else: 86 | warnings.resetwarnings() 87 | 88 | self.server_version = self.__fetch_server_version_safe() 89 | if self.server_version is None: 90 | raise AssertionError("Communication with Immich Server API failed! Make sure the API URL is correct and verify the API Key!") 91 | 92 | # Check version 93 | if self.server_version ['major'] == 1 and self.server_version ['minor'] < 106: 94 | raise AssertionError("This script only works with Immich Server v1.106.0 and newer! Update Immich Server or use script version 0.8.1!") 95 | 96 | def __validate_config(self): 97 | """ 98 | Validates all set configuration values. 99 | 100 | :raises: ValueError if any config value does not pass validation 101 | """ 102 | Utils.assert_not_none_or_empty("api_url", self.api_url) 103 | Utils.assert_not_none_or_empty("api_key", self.api_key) 104 | Utils.assert_not_none_or_empty("chunk_size", self.chunk_size) 105 | Utils.assert_not_none_or_empty("fetch_chunk_size", self.fetch_chunk_size) 106 | Utils.assert_not_none_or_empty("api_timeout", self.api_timeout) 107 | Utils.assert_not_none_or_empty("insecure", self.insecure) 108 | 109 | if not Utils.is_integer(self.chunk_size) or self.chunk_size < 1: 110 | raise ValueError("chunk_size must be an integer > 0!") 111 | 112 | if not Utils.is_integer(self.fetch_chunk_size) or self.fetch_chunk_size < 1: 113 | raise ValueError("fetch_chunk_size must be an integer > 0!") 114 | 115 | if not Utils.is_integer(self.api_timeout) or self.api_timeout < 1: 116 | raise ValueError("api_timeout must be an integer > 0!") 117 | 118 | try: 119 | bool(self.insecure) 120 | except ValueError as e: 121 | raise ValueError("insecure argument must be a boolean!") from e 122 | 123 | def __request_api(self, http_method: str, endpoint: str, body: any = None, no_retries: bool = False) -> any: 124 | """ 125 | Performs an HTTP request using `http_method` to `endpoint`, sending headers `self.request_args` and `body` as payload. 126 | Uses `self.api_timeout` as a timeout and respects `self.max_retry_count` on timeout if `not_retries` is not `True`. 127 | If the HTTP request fails for any other reason than a timeout, no retries are performed. 128 | 129 | :param http_method: The HTTP method to send the request with, must be one of `GET`, `POST`, `PUT`, `DELETE`, `HEAD`, `CONNECT`, `OPTIONS`, `TRACE` or `PATCH`. 130 | :param endpoint: The URL to request 131 | :param body: The request body to send. Defaults to an empty dict. 132 | :param no_retries: Flag indicating whether to fail immediately on request timeout 133 | 134 | :returns: The HTTP response 135 | :raises: HTTPError if the request failed (timeouts only if occurred too many times) 136 | """ 137 | 138 | http_method_function = getattr(requests, http_method) 139 | assert http_method_function is not None 140 | 141 | if body is None: 142 | body = {} 143 | number_of_retries : int = 0 144 | ex = None 145 | while number_of_retries == 0 or ( not no_retries and number_of_retries <= self.max_retry_count ): 146 | try: 147 | return http_method_function(endpoint, **self.request_args, json=body , timeout=self.api_timeout) 148 | except (requests.exceptions.Timeout, urllib3.exceptions.ReadTimeoutError, requests.exceptions.ConnectionError) as e: 149 | # either not a ConnectionError or a ConectionError caused by ReadTimeoutError 150 | if not isinstance(e, requests.exceptions.ConnectionError) or (len(e.args) > 0 and isinstance(e.args[0], urllib3.exceptions.ReadTimeoutError)): 151 | ex = e 152 | number_of_retries += 1 153 | if number_of_retries > self.max_retry_count: 154 | raise e 155 | logging.warning("Request to %s timed out, retry %s...", endpoint, number_of_retries) 156 | else: 157 | raise e 158 | # this point should not be reached 159 | raise ex 160 | 161 | @staticmethod 162 | def __check_api_response(response: requests.Response) -> None: 163 | """ 164 | Checks the HTTP return code for the provided response and 165 | logs any errors before raising an HTTPError 166 | 167 | :param response: A list of asset IDs to archive 168 | :raises: HTTPError if the API call fails 169 | """ 170 | try: 171 | response.raise_for_status() 172 | except HTTPError: 173 | if response.json(): 174 | logging.error("Error in API call: %s", response.json()) 175 | else: 176 | logging.error("API response did not contain a payload") 177 | response.raise_for_status() 178 | 179 | def fetch_server_version(self) -> dict: 180 | """ 181 | Fetches the API version from the immich server. 182 | 183 | If the API endpoint for getting the server version cannot be reached, 184 | raises HTTPError 185 | 186 | :returns: Dictionary with keys `major`, `minor`, `patch` 187 | :rtype: dict 188 | :raises ConnectionError: If the connection to the API server cannot be establisehd 189 | :raises JSONDecodeError: If the API response cannot be parsed 190 | :raises ValueError: If the API response is malformed 191 | """ 192 | api_endpoint = f'{self.api_url}server/version' 193 | r = self.__request_api('get', api_endpoint, self.request_args) 194 | # The API endpoint changed in Immich v1.118.0, if the new endpoint 195 | # was not found try the legacy one 196 | if r.status_code == 404: 197 | api_endpoint = f'{self.api_url}server-info/version' 198 | r = self.__request_api('get', api_endpoint, self.request_args) 199 | 200 | if r.status_code == 200: 201 | server_version = r.json() 202 | try: 203 | assert server_version['major'] is not None 204 | assert server_version['minor'] is not None 205 | assert server_version['patch'] is not None 206 | except AssertionError as e: 207 | raise ValueError from e 208 | logging.info("Detected Immich server version %s.%s.%s", server_version['major'], server_version['minor'], server_version['patch']) 209 | return server_version 210 | return None 211 | 212 | # pylint: disable=W0718 213 | # Catching too general exception Exception (broad-exception-caught 214 | # That's the whole point of this method 215 | def __fetch_server_version_safe(self) -> dict: 216 | """ 217 | Fetches the API version from the Immich server, suppressing any raised errors. 218 | On error, an error message is getting logged to ERRRO level, and the exception stacktrace 219 | is logged to DEBUG level. 220 | 221 | :returns: Dictionary with keys `major`, `minor`, `patch` or None in case of an error 222 | :rtype: dict 223 | """ 224 | try: 225 | return self.fetch_server_version() 226 | except Exception: 227 | # JSONDecodeError happens if the URL is valid, but does not return valid JSON 228 | # Anything below this line is deemed an error 229 | logging.debug("Error requesting server version!") 230 | logging.debug(traceback.format_exc()) 231 | return None 232 | 233 | 234 | def fetch_assets(self, is_not_in_album: bool, visibility_options: list[str]) -> list[dict]: 235 | """ 236 | Fetches assets from the Immich API. 237 | 238 | Uses the /search/meta-data call. Much more efficient than the legacy method 239 | since this call allows to filter for assets that are not in an album only. 240 | 241 | :param is_not_in_album: Flag indicating whether to fetch only assets that are not part 242 | of an album or not. If set to False, will find images in albums and not part of albums 243 | :param visibility_options: A list of visibility options to find and return assets with 244 | :returns: An array of asset objects 245 | :rtype: list[dict] 246 | """ 247 | if self.server_version['major'] == 1 and self.server_version['minor'] < 133: 248 | return self.fetch_assets_with_options({'isNotInAlbum': is_not_in_album, 'withArchived': 'archive' in visibility_options}) 249 | 250 | asset_list = self.fetch_assets_with_options({'isNotInAlbum': is_not_in_album}) 251 | for visiblity_option in visibility_options: 252 | # Do not fetch agin for 'timeline', that's the default! 253 | if visiblity_option != 'timeline': 254 | asset_list += self.fetch_assets_with_options({'isNotInAlbum': is_not_in_album, 'visibility': visiblity_option}) 255 | return asset_list 256 | 257 | def fetch_assets_with_options(self, search_options: dict[str]) -> list[dict]: 258 | """ 259 | Fetches assets from the Immich API using specific search options. 260 | The search options directly correspond to the body used for the search API request. 261 | 262 | :param search_options: Dictionary containing options to pass to the search/metadata API endpoint 263 | :returns: An array of asset objects 264 | :rtype: list[dict] 265 | """ 266 | body = search_options 267 | assets_found = [] 268 | # prepare request body 269 | 270 | # This API call allows a maximum page size of 1000 271 | number_of_assets_to_fetch_per_request_search = min(1000, self.fetch_chunk_size) 272 | body['size'] = number_of_assets_to_fetch_per_request_search 273 | # Initial API call, let's fetch our first chunk 274 | page = 1 275 | body['page'] = str(page) 276 | r = self.__request_api('post', self.api_url+'search/metadata', body) 277 | r.raise_for_status() 278 | response_json = r.json() 279 | assets_received = response_json['assets']['items'] 280 | logging.debug("Received %s assets with chunk %s", len(assets_received), page) 281 | 282 | assets_found = assets_found + assets_received 283 | # If we got a full chunk size back, let's perform subsequent calls until we get less than a full chunk size 284 | while len(assets_received) == number_of_assets_to_fetch_per_request_search: 285 | page += 1 286 | body['page'] = page 287 | r = self.__request_api('post', self.api_url+'search/metadata', body) 288 | self.__check_api_response(r) 289 | response_json = r.json() 290 | assets_received = response_json['assets']['items'] 291 | logging.debug("Received %s assets with chunk %s", len(assets_received), page) 292 | assets_found = assets_found + assets_received 293 | return assets_found 294 | 295 | def fetch_albums(self) -> list[dict]: 296 | """ 297 | Fetches albums from the Immich API 298 | 299 | :returns: A list of album objects 300 | :rtype: list[dict] 301 | """ 302 | 303 | api_endpoint = 'albums' 304 | 305 | r = self.__request_api('get', self.api_url+api_endpoint) 306 | self.__check_api_response(r) 307 | return r.json() 308 | 309 | def fetch_album_info(self, album_id_for_info: str) -> dict: 310 | """ 311 | Fetches information about a specific album 312 | 313 | :param album_id_for_info: The ID of the album to fetch information for 314 | 315 | :returns: A dict containing album information 316 | :rtype: dict 317 | """ 318 | 319 | api_endpoint = f'albums/{album_id_for_info}' 320 | 321 | r = self.__request_api('get', self.api_url+api_endpoint) 322 | self.__check_api_response(r) 323 | return r.json() 324 | 325 | def delete_album(self, album_delete: dict) -> bool: 326 | """ 327 | Deletes an album identified by album_to_delete['id'] 328 | 329 | If the album could not be deleted, logs an error. 330 | 331 | :param album_delete: Dictionary with the following keys: `id`, `albumName` 332 | 333 | :returns: True if the album was deleted, otherwise False 334 | :rtype: bool 335 | """ 336 | api_endpoint = 'albums' 337 | 338 | logging.debug("Deleting Album: Album ID = %s, Album Name = %s", album_delete['id'], album_delete['albumName']) 339 | r = self.__request_api('delete', self.api_url+api_endpoint+'/'+album_delete['id']) 340 | try: 341 | self.__check_api_response(r) 342 | return True 343 | except HTTPError: 344 | logging.error("Error deleting album %s: %s", album_delete['albumName'], r.reason) 345 | return False 346 | 347 | def create_album(self, album_name_to_create: str) -> str: 348 | """ 349 | Creates an album with the provided name and returns the ID of the created album 350 | 351 | 352 | :param album_name_to_create: Name of the album to create 353 | 354 | :returns: True if the album was deleted, otherwise False 355 | :rtype: str 356 | 357 | :raises: Exception if the API call failed 358 | """ 359 | 360 | api_endpoint = 'albums' 361 | 362 | data = { 363 | 'albumName': album_name_to_create 364 | } 365 | r = self.__request_api('post', self.api_url+api_endpoint, data) 366 | self.__check_api_response(r) 367 | 368 | return r.json()['id'] 369 | 370 | def add_assets_to_album(self, assets_add_album_id: str, asset_list: list[str]) -> list[str]: 371 | """ 372 | Adds the assets IDs provided in assets to the provided albumId. 373 | 374 | If assets if larger than self.chunk_size, the list is chunked 375 | and one API call is performed per chunk. 376 | Only logs errors and successes. 377 | 378 | Returns 379 | 380 | :param assets_add_album_id: The ID of the album to add assets to 381 | :param asset_list: A list of asset IDs to add to the album 382 | 383 | :returns: The asset UUIDs that were actually added to the album (not respecting assets that were already part of the album) 384 | :rtype: list[str] 385 | """ 386 | api_endpoint = 'albums' 387 | 388 | # Divide our assets into chunks of self.chunk_size, 389 | # So the API can cope 390 | assets_chunked = list(Utils.divide_chunks(asset_list, self.chunk_size)) 391 | asset_list_added = [] 392 | 393 | for assets_chunk in assets_chunked: 394 | data = {'ids':assets_chunk} 395 | r = self.__request_api('put', self.api_url+api_endpoint+f'/{assets_add_album_id}/assets', data) 396 | self.__check_api_response(r) 397 | response = r.json() 398 | 399 | for res in response: 400 | if not res['success']: 401 | if res['error'] != 'duplicate': 402 | logging.warning("Error adding an asset to an album: %s", res['error']) 403 | else: 404 | asset_list_added.append(res['id']) 405 | 406 | return asset_list_added 407 | 408 | def fetch_users(self) -> list[dict]: 409 | """ 410 | Queries and returns all users 411 | 412 | :returns: A list of user objects 413 | :rtype: list[dict] 414 | """ 415 | 416 | api_endpoint = 'users' 417 | 418 | r = self.__request_api('get', self.api_url+api_endpoint) 419 | self.__check_api_response(r) 420 | return r.json() 421 | 422 | def unshare_album_with_user(self, album_id_to_unshare: str, unshare_user_id: str) -> None: 423 | """ 424 | Unshares the provided album with the provided user 425 | 426 | :param album_id_to_unshare: The ID of the album to unshare 427 | :param unshare_user_id: The user ID to remove from the album's share list 428 | 429 | :raises: HTTPError if the API call fails 430 | """ 431 | api_endpoint = f'albums/{album_id_to_unshare}/user/{unshare_user_id}' 432 | r = self.__request_api('delete', self.api_url+api_endpoint) 433 | self.__check_api_response(r) 434 | 435 | def update_album_share_user_role(self, album_id_to_share: str, share_user_id: str, share_user_role: str) -> None: 436 | """ 437 | Updates the user's share role for the provided album ID. 438 | 439 | :param album_id_to_share: The ID of the album to share 440 | :param share_user_id: The user ID to update the share role for 441 | :param share_user_role: The share role to update the user to 442 | 443 | :raises: AssertionError if user_share_role contains an invalid value 444 | :raises: HTTPError if the API call fails 445 | """ 446 | api_endpoint = f'albums/{album_id_to_share}/user/{share_user_id}' 447 | 448 | assert share_user_role in ApiClient.SHARE_ROLES 449 | 450 | data = { 451 | 'role': share_user_role 452 | } 453 | 454 | r = self.__request_api('put', self.api_url+api_endpoint, data) 455 | self.__check_api_response(r) 456 | 457 | def share_album_with_user_and_role(self, album_id_to_share: str, user_ids_to_share_with: list[str], user_share_role: str) -> None: 458 | """ 459 | Shares the album with the provided album_id with all provided share_user_ids 460 | using share_role as a role. 461 | 462 | :param album_id_to_share: The ID of the album to share 463 | :param user_ids_to_share_with: IDs of users to share the album with 464 | :param user_share_role: The share role to use when sharing the album, valid values are `viewer` or `editor` 465 | :raises: AssertionError if user_share_role contains an invalid value 466 | :raises: HTTPError if the API call fails 467 | """ 468 | api_endpoint = f'albums/{album_id_to_share}/users' 469 | 470 | assert user_share_role in ApiClient.SHARE_ROLES 471 | 472 | # build payload 473 | album_users = [] 474 | for user_id_to_share_with in user_ids_to_share_with: 475 | share_info = {} 476 | share_info['role'] = user_share_role 477 | share_info['userId'] = user_id_to_share_with 478 | album_users.append(share_info) 479 | 480 | data = { 481 | 'albumUsers': album_users 482 | } 483 | 484 | r = self.__request_api('put', self.api_url+api_endpoint, data) 485 | self.__check_api_response(r) 486 | 487 | def trigger_offline_asset_removal(self) -> None: 488 | """ 489 | Removes offline assets. 490 | 491 | Takes into account API changes happening between v1.115.0 and v1.116.0. 492 | 493 | Before v1.116.0, offline asset removal was an asynchronous job that could only be 494 | triggered by an Administrator for a specific library. 495 | 496 | Since v1.116.0, offline assets are no longer displayed in the main timeline but shown in the trash. They automatically 497 | come back from the trash when they are no longer offline. The only way to delete them is either by emptying the trash 498 | (along with everything else) or by selectively deleting all offline assets. This is option the script now uses. 499 | 500 | :raises: HTTPException if any API call fails 501 | """ 502 | if self.server_version['major'] == 1 and self.server_version['minor'] < 116: 503 | self.__trigger_offline_asset_removal_pre_minor_version_116() 504 | else: 505 | self.__trigger_offline_asset_removal_since_minor_version_116() 506 | 507 | def __trigger_offline_asset_removal_since_minor_version_116(self) -> None: 508 | """ 509 | Synchronously deletes offline assets. 510 | 511 | Uses the searchMetadata endpoint to find all assets marked as offline, then 512 | issues a delete call for these asset UUIDs. 513 | 514 | :raises: HTTPException if any API call fails 515 | """ 516 | # Workaround for a bug where isOffline option is not respected: 517 | # Search all trashed assets and manually filter for offline assets. 518 | # WARNING! This workaround must NOT be removed to keep compatibility with Immich v1.116.x to at 519 | # least v1.117.x (reported issue for v1.117.0, might be fixed with v1.118.0)! 520 | # If removed the assets for users of v1.116.0 - v1.117.x might be deleted completely!!! 521 | # 2024/03/01: With Immich v1.128.0 isOffline filter is fixed. Remember to also request archived assets. 522 | trashed_assets = self.fetch_assets_with_options({'isTrashed': True, 'isOffline': True, 'withArchived': True}) 523 | #logging.debug("search results: %s", offline_assets) 524 | 525 | offline_assets = [asset for asset in trashed_assets if asset['isOffline']] 526 | 527 | if len(offline_assets) > 0: 528 | logging.info("Deleting %s offline assets", len(offline_assets)) 529 | logging.debug("Deleting the following offline assets (count: %d): %s", len(offline_assets), [asset['originalPath'] for asset in offline_assets]) 530 | self.delete_assets(offline_assets, True) 531 | else: 532 | logging.info("No offline assets found!") 533 | 534 | def __trigger_offline_asset_removal_pre_minor_version_116(self): 535 | """ 536 | Triggers Offline Asset Removal Job. 537 | Only supported in Immich prior v1.116.0. 538 | Requires the script to run with an Administrator level API key. 539 | 540 | Works by fetching all libraries and triggering the Offline Asset Removal job 541 | one by one. 542 | 543 | :raises: HTTPError if the API call fails 544 | """ 545 | libraries = self.fetch_libraries() 546 | for library in libraries: 547 | self.__trigger_offline_asset_removal_async(library['id']) 548 | 549 | def delete_assets(self, assets_to_delete: list, force: bool): 550 | """ 551 | Deletes the provided assets from Immich. 552 | 553 | :param assets_to_delete: A list of asset objects with key `id` 554 | :param force: Force flag to pass to the API call 555 | 556 | :raises: HTTPException if the API call fails 557 | """ 558 | 559 | api_endpoint = 'assets' 560 | asset_ids_to_delete = [asset['id'] for asset in assets_to_delete] 561 | data = { 562 | 'force': force, 563 | 'ids': asset_ids_to_delete 564 | } 565 | 566 | r = self.__request_api('delete', self.api_url+api_endpoint, data) 567 | self.__check_api_response(r) 568 | 569 | def __trigger_offline_asset_removal_async(self, library_id: str): 570 | """ 571 | Triggers removal of offline assets in the library identified by libraryId. 572 | 573 | :param library_id: The ID of the library to trigger offline asset removal for 574 | :raises: Exception if any API call fails 575 | """ 576 | 577 | api_endpoint = f'libraries/{library_id}/removeOffline' 578 | 579 | r = self.__request_api('post', self.api_url+api_endpoint) 580 | if r.status_code == 403: 581 | logging.fatal("--sync-mode 2 requires an Admin User API key!") 582 | else: 583 | self.__check_api_response(r) 584 | 585 | def fetch_libraries(self) -> list[dict]: 586 | """ 587 | Queries and returns all libraries 588 | 589 | :raises: Exception if any API call fails 590 | """ 591 | 592 | api_endpoint = 'libraries' 593 | 594 | r = self.__request_api('get', self.api_url+api_endpoint) 595 | self.__check_api_response(r) 596 | return r.json() 597 | 598 | def set_album_thumb(self, thumbnail_album_id: str, thumbnail_asset_id: str): 599 | """ 600 | Sets asset as thumbnail of album 601 | 602 | :param thumbnail_album_id: The ID of the album for which to set the thumbnail 603 | :param thumbnail_asset_id: The ID of the asset to be set as thumbnail 604 | 605 | :raises: Exception if the API call fails 606 | """ 607 | api_endpoint = f'albums/{thumbnail_album_id}' 608 | 609 | data = {"albumThumbnailAssetId": thumbnail_asset_id} 610 | 611 | r = self.__request_api('patch', self.api_url+api_endpoint, data) 612 | self.__check_api_response(r) 613 | 614 | def update_album_properties(self, album_to_update: AlbumModel): 615 | """ 616 | Sets album properties in Immich to the properties of the AlbumModel 617 | 618 | :param album_to_update: The album model to use for updating the album 619 | 620 | :raises: Exception if the API call fails 621 | """ 622 | # Initialize payload 623 | data = {} 624 | 625 | # Thumbnail Asset 626 | if album_to_update.thumbnail_asset_uuid: 627 | data['albumThumbnailAssetId'] = album_to_update.thumbnail_asset_uuid 628 | 629 | # Description 630 | if album_to_update.description: 631 | data['description'] = album_to_update.description 632 | 633 | # Sorting Order 634 | if album_to_update.sort_order: 635 | data['order'] = album_to_update.sort_order 636 | 637 | # Comments / Likes enabled 638 | if album_to_update.comments_and_likes_enabled is not None: 639 | data['isActivityEnabled'] = album_to_update.comments_and_likes_enabled 640 | 641 | # Only update album if there is something to update 642 | if len(data) > 0: 643 | api_endpoint = f'albums/{album_to_update.id}' 644 | 645 | response = self.__request_api('patch',self.api_url+api_endpoint, data) 646 | self.__check_api_response(response) 647 | 648 | def set_assets_visibility(self, asset_ids_for_visibility: list[str], visibility_setting: str): 649 | """ 650 | Sets the visibility of assets identified by the passed list of UUIDs. 651 | 652 | :param asset_ids_for_visibility: A list of asset IDs to set visibility for 653 | :param visibility: The visibility to set 654 | 655 | :raises: Exception if the API call fails 656 | """ 657 | api_endpoint = 'assets' 658 | data = {"ids": asset_ids_for_visibility} 659 | # Remove when minimum supported version is >= 133 660 | if self.server_version['major'] == 1 and self.server_version ['minor'] < 133: 661 | if visibility_setting is not None and visibility_setting not in ['archive', 'timeline']: 662 | # Warnings have been logged earlier, silently abort 663 | return 664 | is_archived = True 665 | if visibility_setting == 'timeline': 666 | is_archived = False 667 | data["isArchived"] = is_archived 668 | # Up-to-date Immich Server versions 669 | else: 670 | data["visibility"] = visibility_setting 671 | 672 | r = self.__request_api('put', self.api_url+api_endpoint, data) 673 | self.__check_api_response(r) 674 | 675 | def delete_all_albums(self, assets_visibility: str, force_delete: bool): 676 | """ 677 | Deletes all albums in Immich if force_delete is True. Otherwise lists all albums 678 | that would be deleted. 679 | If assets_visibility is set, all assets in deleted albums 680 | will be set to that visibility. 681 | 682 | :param assets_visibility: Flag indicating whether to unarchive archived assets 683 | :param force_delete: Flag indicating whether to actually delete albums (True) or only to perform a dry-run (False) 684 | 685 | :raises: HTTPError if the API call fails 686 | """ 687 | 688 | all_albums = self.fetch_albums() 689 | logging.info("%d existing albums identified", len(all_albums)) 690 | # Delete Confirm check 691 | if not force_delete: 692 | album_names = [] 693 | for album_to_delete in all_albums: 694 | album_names.append(album_to_delete['albumName']) 695 | print("Would delete the following albums (ALL albums!):") 696 | print(album_names) 697 | if is_docker: 698 | print("Run the container with environment variable DELETE_CONFIRM set to 1 to actually delete these albums!") 699 | else: 700 | print("Call with --delete-confirm to actually delete albums!") 701 | sys.exit(0) 702 | 703 | deleted_album_count = 0 704 | for album_to_delete in all_albums: 705 | if self.delete_album(album_to_delete): 706 | # If the archived flag is set it means we need to unarchived all images of deleted albums; 707 | # In order to do so, we need to fetch all assets of the album we're going to delete 708 | assets_in_deleted_album = [] 709 | if assets_visibility is not None: 710 | album_to_delete_info = self.fetch_album_info(album_to_delete['id']) 711 | assets_in_deleted_album = album_to_delete_info['assets'] 712 | logging.info("Deleted album %s", album_to_delete['albumName']) 713 | deleted_album_count += 1 714 | if len(assets_in_deleted_album) > 0 and assets_visibility is not None: 715 | self.set_assets_visibility([asset['id'] for asset in assets_in_deleted_album], assets_visibility) 716 | logging.info("Set visibility for %d assets to %s", len(assets_in_deleted_album), assets_visibility) 717 | logging.info("Deleted %d/%d albums", deleted_album_count, len(all_albums)) 718 | 719 | def cleanup_albums(self, albums_to_delete: list[AlbumModel], asset_visibility: str, force_delete: bool) -> int: 720 | """ 721 | Instead of creating, deletes albums in Immich if force_delete is True. Otherwise lists all albums 722 | that would be deleted. 723 | If unarchived_assets is set to true, all archived assets in deleted albums 724 | will be unarchived. 725 | 726 | :param albums_to_delete: A list of AlbumModel records to delete 727 | :param asset_visibility: The visibility to set for assets for which an album was deleted. Can be used to. e.g. revert archival 728 | :param force_delete: Flag indicating whether to actually delete albums (True) or only to perform a dry-run (False) 729 | 730 | :returns: Number of successfully deleted albums 731 | :rtype: int 732 | 733 | :raises: HTTPError if the API call fails 734 | """ 735 | # Delete Confirm check 736 | if not force_delete: 737 | print("Would delete the following albums:") 738 | print([a.get_final_name() for a in albums_to_delete]) 739 | if is_docker: 740 | print("Run the container with environment variable DELETE_CONFIRM set to 1 to actually delete these albums!") 741 | else: 742 | print(" Call with --delete-confirm to actually delete albums!") 743 | return 0 744 | 745 | # At this point force_delete is true! 746 | cpt = 0 747 | for album_to_delete in albums_to_delete: 748 | # If the archived flag is set it means we need to unarchived all images of deleted albums; 749 | # In order to do so, we need to fetch all assets of the album we're going to delete 750 | assets_in_album = [] 751 | # For cleanup, we only respect the global visibility flag to be able to either do not change 752 | # visibility at all, or to override whatever might be set in .ablumprops and revert to something else 753 | if asset_visibility is not None: 754 | album_to_delete_info = self.fetch_album_info(album_to_delete.id) 755 | assets_in_album = album_to_delete_info['assets'] 756 | if self.delete_album({'id': album_to_delete.id, 'albumName': album_to_delete.get_final_name()}): 757 | logging.info("Deleted album %s", album_to_delete.get_final_name()) 758 | cpt += 1 759 | # Archive flag is set, so we need to unarchive assets 760 | if asset_visibility is not None and len(assets_in_album) > 0: 761 | self.set_assets_visibility([asset['id'] for asset in assets_in_album], asset_visibility) 762 | logging.info("Set visibility for %d assets to %s", len(assets_in_album), asset_visibility) 763 | return cpt 764 | 765 | # Disable pylint for too many branches 766 | # pylint: disable=R0912,R0914 767 | def update_album_shared_state(self, album_to_share: AlbumModel, unshare_users: bool, known_users: list[dict]) -> None: 768 | """ 769 | Makes sure the album is shared with the users set in the model with the correct roles. 770 | This involves fetching album info from Immich to check if/who the album is shared with and the share roles, 771 | then either updating the share role, removing the user, or adding the users 772 | 773 | :param album_to_share: The album to share, with the expected share_with setting 774 | :param unshare_users: Flag indicating whether to actively unshare albums if shared with a user that is not in the current share settings 775 | :param known_users: A list of all users Immich knows to find the user IDs to share/unshare with 776 | 777 | :raises: HTTPError if the API call fails 778 | """ 779 | # Parse and prepare expected share roles 780 | # List all share users by share role 781 | share_users_to_roles_expected = {} 782 | for share_user in album_to_share.share_with: 783 | # Find the user by configured name or email 784 | share_user_in_immich = FolderAlbumCreator.find_user_by_name_or_email(share_user['user'], known_users) 785 | if not share_user_in_immich: 786 | logging.warning("User %s to share album %s with does not exist!", share_user['user'], album_to_share.get_final_name()) 787 | continue 788 | # Use 'viewer' as default role if not specified 789 | share_role_local = share_user.get('role', 'viewer') 790 | share_users_to_roles_expected[share_user_in_immich['id']] = share_role_local 791 | 792 | # No users to share with and unsharing is disabled? 793 | if len(share_users_to_roles_expected) == 0 and not unshare_users: 794 | return 795 | 796 | # Now fetch reality 797 | album_to_share_info = self.fetch_album_info(album_to_share.id) 798 | # Dict mapping a user ID to share role 799 | album_share_info = {} 800 | for share_user_actual in album_to_share_info['albumUsers']: 801 | album_share_info[share_user_actual['user']['id']] = share_user_actual['role'] 802 | 803 | # Group share users by share role 804 | share_roles_to_users_expected = {} 805 | # Now compare expectation with reality and update 806 | for user_to_share_with, share_role_expected in share_users_to_roles_expected.items(): 807 | # Case: Album is not share with user 808 | if user_to_share_with not in album_share_info: 809 | # Gather all users to share the album with for this role 810 | if not share_role_expected in share_roles_to_users_expected: 811 | share_roles_to_users_expected[share_role_expected] = [] 812 | share_roles_to_users_expected[share_role_expected].append(user_to_share_with) 813 | 814 | # Case: Album is shared, but with wrong role 815 | elif album_share_info[user_to_share_with] != share_role_expected: 816 | try: 817 | self.update_album_share_user_role(album_to_share.id, user_to_share_with, share_role_expected) 818 | logging.debug("Sharing: Updated share role for user %s in album %s to %s", user_to_share_with, album_to_share.get_final_name(), share_role_expected) 819 | except HTTPError as ex: 820 | logging.warning("Sharing: Error updating share role for user %s in album %s to %s", user_to_share_with, album_to_share.get_final_name(), share_role_expected) 821 | logging.debug("Error: %s", ex) 822 | 823 | # Now check if the album is shared with any users it should not be shared with 824 | if unshare_users: 825 | for shared_user in album_share_info: 826 | if shared_user not in share_users_to_roles_expected: 827 | try: 828 | self.unshare_album_with_user(album_to_share.id, shared_user) 829 | logging.debug("Sharing: User %s removed from album %s", shared_user, album_to_share.get_final_name()) 830 | except HTTPError as ex: 831 | logging.warning("Sharing: Error removing user %s from album %s", shared_user, album_to_share.get_final_name()) 832 | logging.debug("Error: %s", ex) 833 | 834 | # Now share album with all users it is not already shared with 835 | if len(share_roles_to_users_expected) > 0: 836 | # Share album for users by role 837 | for share_role_group, share_users in share_roles_to_users_expected.items(): 838 | # Convert list of user dicts to list of user IDs 839 | try: 840 | self.share_album_with_user_and_role(album_to_share.id, share_users, share_role_group) 841 | logging.debug("Album %s shared with users IDs %s in role: %s", album_to_share.get_final_name(), share_users, share_role_group) 842 | except (AssertionError, HTTPError) as ex: 843 | logging.warning("Error sharing album %s for users %s in role %s", album_to_share.get_final_name(), share_users, share_role_group) 844 | logging.debug("Album share error: %s", ex) 845 | 846 | class AlbumMergeError(Exception): 847 | """Error thrown when trying to override an existing property""" 848 | 849 | class AlbumModelValidationError(Exception): 850 | """Error thrown when validating album model plausibility fails""" 851 | 852 | # Disable pylint rule for too many instance attributes 853 | # pylint: disable=R0902 854 | class AlbumModel: 855 | """Model of an album with all properties necessary for handling albums in the scope of this script""" 856 | # Album Merge Mode indicating only properties should be merged that are 857 | # not already set in the merge target 858 | ALBUM_MERGE_MODE_EXCLUSIVE = 1 859 | # Same as ALBUM_MERGE_MODE_EXCLUSIVE, but also raises an error 860 | # if attempting to overwrite an existing property when merging 861 | ALBUM_MERGE_MODE_EXCLUSIVE_EX = 2 862 | # Override any property in the merge target if already exists 863 | ALBUM_MERGE_MODE_OVERRIDE = 3 864 | # List of class attribute names that are relevant for album properties handling 865 | # This list is used for album model merging and validation 866 | ALBUM_PROPERTIES_VARIABLES = ['override_name', 'description', 'share_with', 'thumbnail_setting', 'sort_order', 'archive', 'visibility', 'comments_and_likes_enabled'] 867 | 868 | # List of class attribute names that are relevant for inheritance 869 | ALBUM_INHERITANCE_VARIABLES = ['inherit', 'inherit_properties'] 870 | 871 | def __init__(self, name : str): 872 | # The album ID, set after it was created 873 | self.id = None 874 | # The album name 875 | self.name = name 876 | # The override album name, takes precedence over name for album creation 877 | self.override_name = None 878 | # The description to set for the album 879 | self.description = None 880 | # A list of dicts with Immich assets 881 | self.assets = [] 882 | # a list of dicts with keys user and role, listing all users and their role to share the album with 883 | self.share_with = [] 884 | # Either a fully qualified asset path or one of 'first', 'last', 'random' 885 | self.thumbnail_setting = None 886 | self.thumbnail_asset_uuid = None 887 | # Sorting order for this album, 'asc' or 'desc' 888 | self.sort_order = None 889 | # Boolean indicating whether assets in this album should be archived after adding 890 | # Deprecated, use visibility = archive instead! 891 | self.archive = None 892 | # String indicating asset visibility, allowed values: archive, hidden, locked, timeline 893 | self.visibility = None 894 | # Boolean indicating whether assets in this albums can be commented on and liked 895 | self.comments_and_likes_enabled = None 896 | # Boolean indicating whether properties should be inherited down the directory tree 897 | self.inherit = None 898 | # List of property names that should be inherited 899 | self.inherit_properties = None 900 | 901 | def get_album_properties_dict(self) -> dict: 902 | """ 903 | Returns this class' attributes relevant for album properties handling 904 | as a dictionary 905 | 906 | :returns: A dictionary of all album properties 907 | :rtype: dict 908 | """ 909 | props = dict(vars(self)) 910 | for prop in list(props.keys()): 911 | if prop not in AlbumModel.ALBUM_PROPERTIES_VARIABLES: 912 | del props[prop] 913 | return props 914 | 915 | def __str__(self) -> str: 916 | """ 917 | Returns a string representation of this most important album properties 918 | 919 | :returns: A string for printing this album model's properties 920 | :rtype: str 921 | """ 922 | return str(self.get_album_properties_dict()) 923 | 924 | def get_asset_uuids(self) -> list[str]: 925 | """ 926 | Gathers UUIDs of all assets and returns them 927 | 928 | :returns: A list of asset UUIDs 929 | :rtype: list[str] 930 | """ 931 | return [asset_to_add['id'] for asset_to_add in self.assets] 932 | 933 | def find_incompatible_properties(self, other) -> list[str]: 934 | """ 935 | Checks whether this Album Model and the other album model are compatible in terms of 936 | describing the same album for creation in a way that no album properties are in conflict 937 | with each other. 938 | All properties must either bei the same or not present in both objects, except for 939 | - `id` 940 | - `name` 941 | - `assets` 942 | 943 | :param other: The other album model to check against 944 | 945 | :returns: A list of string representations for incompatible properties. The list is empty 946 | if there are no incompatible properties 947 | :rtype: list[str] 948 | """ 949 | if not isinstance(other, AlbumModel): 950 | return False 951 | incompatible_props = [] 952 | props = self.get_album_properties_dict() 953 | other_props = other.get_album_properties_dict() 954 | for prop in props: 955 | if props[prop] != other_props[prop]: 956 | incompatible_props.append(f'{prop}: {props[prop]} vs {other_props[prop]}') 957 | 958 | return incompatible_props 959 | 960 | def merge_from(self, other, merge_mode: int): 961 | """ 962 | Merges properties of other in self. The only properties not 963 | considered for merging are 964 | - `id` 965 | - `name` 966 | - `assets` 967 | 968 | :param other: The other album model to merge properties from 969 | :param merge_mode: Defines how the merge should be performed: 970 | 971 | - `AlbumModel.ALBUM_MERGE_MODE_EXCLUSIVE`: Only merge properties that are not already set in the merge target 972 | - `AlbumModel.ALBUM_MERGE_MODE_EXCLUSIVE_EX`: Same as above, but also raises an exception if attempting to merge an existing property 973 | - `AlbumModel.ALBUM_MERGE_MODE_OVERRIDE`: Overrides any existing property in merge target 974 | """ 975 | # Do not try to merge unrelated types 976 | if not isinstance(other, AlbumModel): 977 | logging.warning("Trying to merge AlbumModel with incompatible type!") 978 | return 979 | own_attribs = vars(self) 980 | other_attribs = vars(other) 981 | 982 | # Override merge mode 983 | if merge_mode == AlbumModel.ALBUM_MERGE_MODE_OVERRIDE: 984 | for prop_name in AlbumModel.ALBUM_PROPERTIES_VARIABLES: 985 | if other_attribs[prop_name]: 986 | own_attribs[prop_name] = other_attribs[prop_name] 987 | 988 | # Exclusive merge modes 989 | elif merge_mode in [AlbumModel.ALBUM_MERGE_MODE_EXCLUSIVE, AlbumModel.ALBUM_MERGE_MODE_EXCLUSIVE_EX]: 990 | for prop_name in AlbumModel.ALBUM_PROPERTIES_VARIABLES: 991 | if other_attribs[prop_name]: 992 | if own_attribs[prop_name] and merge_mode == AlbumModel.ALBUM_MERGE_MODE_EXCLUSIVE_EX: 993 | raise AlbumMergeError(f"Attempting to override {prop_name} in {self.name} with {other_attribs[prop_name]}") 994 | own_attribs[prop_name] = other_attribs[prop_name] 995 | 996 | def merge_inherited_share_with(self, inherited_share_with: list[dict]) -> list[dict]: 997 | """ 998 | Merges inherited share_with settings with current share_with settings. 999 | Handles special share_with inheritance logic: 1000 | - Users can be added from parent folders 1001 | - Users can be removed by setting role to 'none' (and once removed, cannot be re-added) 1002 | - User roles follow most restrictive policy: viewer is more restrictive than editor 1003 | - Most restrictive role wins when there are conflicts 1004 | 1005 | :param inherited_share_with: List of inherited share_with settings from parent folders 1006 | :return: Merged share_with list 1007 | """ 1008 | if not inherited_share_with: 1009 | return self.share_with if self.share_with else [] 1010 | 1011 | if not self.share_with: 1012 | return inherited_share_with 1013 | 1014 | # Create a dict to track users by name/email for easier manipulation 1015 | user_roles = {} 1016 | users_set_to_none = set() # Track users that have been explicitly set to 'none' 1017 | 1018 | # Add inherited users first 1019 | for inherited_user in inherited_share_with: 1020 | if inherited_user['role'] == 'none': 1021 | users_set_to_none.add(inherited_user['user']) 1022 | else: 1023 | user_roles[inherited_user['user']] = inherited_user['role'] 1024 | 1025 | # Apply current folder's share_with settings 1026 | for current_user in self.share_with: 1027 | if current_user['role'] == 'none': 1028 | # Remove user from sharing and mark as explicitly set to none 1029 | user_roles.pop(current_user['user'], None) 1030 | users_set_to_none.add(current_user['user']) 1031 | elif current_user['user'] not in users_set_to_none: 1032 | # Only add/modify user if they haven't been set to 'none' 1033 | if current_user['user'] in user_roles: 1034 | # Apply most restrictive role: viewer is more restrictive than editor 1035 | current_role = user_roles[current_user['user']] 1036 | new_role = current_user['role'] 1037 | 1038 | if current_role == 'viewer' or new_role == 'viewer': 1039 | user_roles[current_user['user']] = 'viewer' 1040 | else: 1041 | user_roles[current_user['user']] = new_role 1042 | else: 1043 | # Add new user with their role 1044 | user_roles[current_user['user']] = current_user['role'] 1045 | 1046 | # Convert back to list format 1047 | return [{'user': user, 'role': role} for user, role in user_roles.items()] 1048 | 1049 | def get_final_name(self) -> str: 1050 | """ 1051 | Gets the album model's name to use when talking to Immich, i.e. 1052 | returns override_name if set, otherwise name. 1053 | 1054 | :returns: override_name if set, otherwise name 1055 | :rtype: str 1056 | """ 1057 | if self.override_name: 1058 | return self.override_name 1059 | return self.name 1060 | 1061 | @staticmethod 1062 | def parse_album_properties_file(album_properties_file_path: str): 1063 | """ 1064 | Parses the provided album properties file into an AlbumModel 1065 | 1066 | :param album_properties_file_path: The fully qualified path to a valid album properties file 1067 | 1068 | :returns: An AlbumModel that represents the album properties 1069 | :rtype: str 1070 | 1071 | :raises YAMLError: If the provided album properties file could not be found or parsed 1072 | """ 1073 | with open(album_properties_file_path, 'r', encoding="utf-8") as stream: 1074 | album_properties = yaml.safe_load(stream) 1075 | if album_properties: 1076 | album_props_template = AlbumModel(None) 1077 | album_props_template_vars = vars(album_props_template) 1078 | 1079 | # Parse standard album properties 1080 | for album_prop_name in AlbumModel.ALBUM_PROPERTIES_VARIABLES: 1081 | if album_prop_name in album_properties: 1082 | album_props_template_vars[album_prop_name] = album_properties[album_prop_name] 1083 | 1084 | # Parse inheritance properties 1085 | for inheritance_prop_name in AlbumModel.ALBUM_INHERITANCE_VARIABLES: 1086 | if inheritance_prop_name in album_properties: 1087 | album_props_template_vars[inheritance_prop_name] = album_properties[inheritance_prop_name] 1088 | 1089 | # Backward compatibility, remove when archive is removed: 1090 | if album_props_template.archive is not None: 1091 | logging.warning("Found deprecated property archive in %s! This will be removed in the future, use visibility: archive instead!", album_properties_file_path) 1092 | if album_props_template.visibility is None: 1093 | album_props_template.visibility = 'archive' 1094 | # End backward compatibility 1095 | return album_props_template 1096 | 1097 | return None 1098 | 1099 | 1100 | class Configuration(): 1101 | """A configuration object for the main class, controlling everything from API key, root path and all the other options the script offers""" 1102 | # Constants holding script run modes 1103 | # Create albums based on folder names and script arguments 1104 | SCRIPT_MODE_CREATE = "CREATE" 1105 | # Create album names based on folder names, but delete these albums 1106 | SCRIPT_MODE_CLEANUP = "CLEANUP" 1107 | # Delete ALL albums 1108 | SCRIPT_MODE_DELETE_ALL = "DELETE_ALL" 1109 | 1110 | # Constants for album thumbnail setting 1111 | ALBUM_THUMBNAIL_RANDOM_ALL = "random-all" 1112 | ALBUM_THUMBNAIL_RANDOM_FILTERED = "random-filtered" 1113 | ALBUM_THUMBNAIL_SETTINGS = ["first", "last", "random"] 1114 | ALBUM_THUMBNAIL_SETTINGS_GLOBAL = ALBUM_THUMBNAIL_SETTINGS + [ALBUM_THUMBNAIL_RANDOM_ALL, ALBUM_THUMBNAIL_RANDOM_FILTERED] 1115 | ALBUM_THUMBNAIL_STATIC_INDICES = { 1116 | "first": 0, 1117 | "last": -1, 1118 | } 1119 | 1120 | # Default values for config options that cannot be None 1121 | CONFIG_DEFAULTS = { 1122 | "api_key_type": "literal", 1123 | "unattended": False, 1124 | "album_levels": 1, 1125 | "album_separator": " ", 1126 | "album_order": False, 1127 | "chunk_size": ApiClient.CHUNK_SIZE_DEFAULT, 1128 | "fetch_chunk_size": ApiClient.FETCH_CHUNK_SIZE_DEFAULT, 1129 | "log_level": "INFO", 1130 | "insecure": False, 1131 | "mode": SCRIPT_MODE_CREATE, 1132 | "delete_confirm": False, 1133 | "share_role": "viewer", 1134 | "sync_mode": 0, 1135 | "find_assets_in_albums": False, 1136 | "find_archived_assets": False, 1137 | "read_album_properties": False, 1138 | "api_timeout": ApiClient.API_TIMEOUT_DEFAULT, 1139 | "comments_and_likes_enabled": False, 1140 | "comments_and_likes_disabled": False, 1141 | "update_album_props_mode": 0, 1142 | "max_retry_count": ApiClient.MAX_RETRY_COUNT_ON_TIMEOUT_DEFAULT 1143 | } 1144 | 1145 | # Static (Global) configuration options 1146 | log_level = CONFIG_DEFAULTS["log_level"] 1147 | 1148 | # Translation of GLOB-style patterns to Regex 1149 | # Source: https://stackoverflow.com/a/63212852 1150 | # FIXME_EVENTUALLY: Replace with glob.translate() introduced with Python 3.13 1151 | __escaped_glob_tokens_to_re = OrderedDict(( 1152 | # Order of ``**/`` and ``/**`` in RE tokenization pattern doesn't matter because ``**/`` will be caught first no matter what, making ``/**`` the only option later on. 1153 | # W/o leading or trailing ``/`` two consecutive asterisks will be treated as literals. 1154 | ('/\\*\\*', '(?:/.+?)*'), # Edge-case #1. Catches recursive globs in the middle of path. Requires edge case #2 handled after this case. 1155 | ('\\*\\*/', '(?:^.+?/)*'), # Edge-case #2. Catches recursive globs at the start of path. Requires edge case #1 handled before this case. ``^`` is used to ensure proper location for ``**/``. 1156 | ('\\*', '[^/]*'), # ``[^/]*`` is used to ensure that ``*`` won't match subdirs, as with naive ``.*?`` solution. 1157 | ('\\?', '.'), 1158 | ('\\[\\*\\]', '\\*'), # Escaped special glob character. 1159 | ('\\[\\?\\]', '\\?'), # Escaped special glob character. 1160 | ('\\[!', '[^'), # Requires ordered dict, so that ``\\[!`` preceded ``\\[`` in RE pattern. 1161 | # Needed mostly to differentiate between ``!`` used within character class ``[]`` and outside of it, to avoid faulty conversion. 1162 | ('\\[', '['), 1163 | ('\\]', ']'), 1164 | )) 1165 | 1166 | __escaped_glob_replacement = regex.compile('(%s)' % '|'.join(__escaped_glob_tokens_to_re).replace('\\', '\\\\\\')) 1167 | 1168 | def __init__(self, args: dict): 1169 | """ 1170 | Instantiates the configuration object using the provided arguments. 1171 | 1172 | :param args: A dictionary containing well-defined key-value pairs 1173 | """ 1174 | self.root_paths = args["root_path"] 1175 | self.root_url = args["api_url"] 1176 | self.api_key = args["api_key"] 1177 | self.chunk_size = Utils.get_value_or_config_default("chunk_size", args, Configuration.CONFIG_DEFAULTS["chunk_size"]) 1178 | self.fetch_chunk_size = Utils.get_value_or_config_default("fetch_chunk_size", args, Configuration.CONFIG_DEFAULTS["fetch_chunk_size"]) 1179 | self.unattended = Utils.get_value_or_config_default("unattended", args, Configuration.CONFIG_DEFAULTS["unattended"]) 1180 | self.album_levels = Utils.get_value_or_config_default("album_levels", args, Configuration.CONFIG_DEFAULTS["album_levels"]) 1181 | # Album Levels Range handling 1182 | self.album_levels_range_arr = () 1183 | self.album_level_separator = Utils.get_value_or_config_default("album_separator", args, Configuration.CONFIG_DEFAULTS["album_separator"]) 1184 | self.album_name_post_regex = args["album_name_post_regex"] 1185 | self.album_order = Utils.get_value_or_config_default("album_order", args, Configuration.CONFIG_DEFAULTS["album_order"]) 1186 | self.insecure = Utils.get_value_or_config_default("insecure", args, Configuration.CONFIG_DEFAULTS["insecure"]) 1187 | self.ignore_albums = args["ignore"] 1188 | self.mode = Utils.get_value_or_config_default("mode", args, Configuration.CONFIG_DEFAULTS["mode"]) 1189 | self.delete_confirm = Utils.get_value_or_config_default("delete_confirm", args, Configuration.CONFIG_DEFAULTS["delete_confirm"]) 1190 | self.share_with = args["share_with"] 1191 | self.share_role = Utils.get_value_or_config_default("share_role", args, Configuration.CONFIG_DEFAULTS["share_role"]) 1192 | self.sync_mode = Utils.get_value_or_config_default("sync_mode", args, Configuration.CONFIG_DEFAULTS["sync_mode"]) 1193 | self.find_assets_in_albums = Utils.get_value_or_config_default("find_assets_in_albums", args, Configuration.CONFIG_DEFAULTS["find_assets_in_albums"]) 1194 | self.path_filter = args["path_filter"] 1195 | self.set_album_thumbnail = args["set_album_thumbnail"] 1196 | self.visibility = args["visibility"] 1197 | self.find_archived_assets = Utils.get_value_or_config_default("find_archived_assets", args, Configuration.CONFIG_DEFAULTS["find_archived_assets"]) 1198 | self.read_album_properties = Utils.get_value_or_config_default("read_album_properties", args, Configuration.CONFIG_DEFAULTS["read_album_properties"]) 1199 | self.api_timeout = Utils.get_value_or_config_default("api_timeout", args, Configuration.CONFIG_DEFAULTS["api_timeout"]) 1200 | self.comments_and_likes_enabled = Utils.get_value_or_config_default("comments_and_likes_enabled", args, Configuration.CONFIG_DEFAULTS["comments_and_likes_enabled"]) 1201 | self.comments_and_likes_disabled = Utils.get_value_or_config_default("comments_and_likes_disabled", args, Configuration.CONFIG_DEFAULTS["comments_and_likes_disabled"]) 1202 | 1203 | self.update_album_props_mode = Utils.get_value_or_config_default("update_album_props_mode", args, Configuration.CONFIG_DEFAULTS["update_album_props_mode"]) 1204 | self.max_retry_count = Utils.get_value_or_config_default('max_retry_count', args, Configuration.CONFIG_DEFAULTS['max_retry_count']) 1205 | 1206 | if self.mode != Configuration.SCRIPT_MODE_CREATE: 1207 | # Override unattended if we're running in destructive mode 1208 | self.unattended = False 1209 | 1210 | # Create ignore regular expressions 1211 | self.ignore_albums_regex = [] 1212 | if self.ignore_albums: 1213 | for ignore_albums_entry in self.ignore_albums: 1214 | self.ignore_albums_regex.append(Configuration.__glob_to_re(Configuration.__expand_to_glob(ignore_albums_entry))) 1215 | 1216 | # Create path filter regular expressions 1217 | self.path_filter_regex = [] 1218 | if self.path_filter: 1219 | for path_filter_entry in self.path_filter: 1220 | self.path_filter_regex.append(Configuration.__glob_to_re(Configuration.__expand_to_glob(path_filter_entry))) 1221 | 1222 | # append trailing slash to all root paths 1223 | # pylint: disable=C0200 1224 | for i in range(len(self.root_paths)): 1225 | if self.root_paths[i][-1] != '/': 1226 | self.root_paths[i] = self.root_paths[i] + '/' 1227 | 1228 | # append trailing slash to root URL 1229 | if self.root_url[-1] != '/': 1230 | self.root_url = self.root_url + '/' 1231 | 1232 | self.__validate_config() 1233 | #self.api_client = new ApiClient() 1234 | 1235 | def __validate_config(self): 1236 | """ 1237 | Validates all set configuration values. 1238 | 1239 | :raises: ValueError if any config value does not pass validation 1240 | """ 1241 | Utils.assert_not_none_or_empty("root_path", self.root_paths) 1242 | Utils.assert_not_none_or_empty("root_url", self.root_url) 1243 | Utils.assert_not_none_or_empty("api_key", self.api_key) 1244 | 1245 | if self.comments_and_likes_disabled and self.comments_and_likes_enabled: 1246 | raise ValueError("Arguments --comments-and-likes-enabled and --comments-and-likes-disabled cannot be used together! Choose one!") 1247 | 1248 | self.__validate_album_range() 1249 | 1250 | def __validate_album_range(self): 1251 | """ 1252 | Performs logic validation for album level range setting. 1253 | """ 1254 | # Verify album levels range 1255 | if not Utils.is_integer(self.album_levels): 1256 | album_levels_range_split = self.album_levels.split(",") 1257 | if any([ 1258 | len(album_levels_range_split) != 2, 1259 | not Utils.is_integer(album_levels_range_split[0]), 1260 | not Utils.is_integer(album_levels_range_split[1]), 1261 | int(album_levels_range_split[0]) == 0, 1262 | int(album_levels_range_split[1]) == 0, 1263 | (int(album_levels_range_split[1]) < 0 <= int(album_levels_range_split[0])), 1264 | (int(album_levels_range_split[0]) < 0 <= int(album_levels_range_split[1])), 1265 | (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])) 1266 | ]): 1267 | raise ValueError(("Invalid album_levels range format! If a range should be set, the start level and end level must be separated by a comma like ','. " 1268 | "If negative levels are used in a range, must be less than or equal to .")) 1269 | self.album_levels_range_arr = album_levels_range_split 1270 | # Convert to int 1271 | self.album_levels_range_arr[0] = int(album_levels_range_split[0]) 1272 | self.album_levels_range_arr[1] = int(album_levels_range_split[1]) 1273 | # Special case: both levels are negative and end level is -1, which is equivalent to just negative album level of start level 1274 | if(self.album_levels_range_arr[0] < 0 and self.album_levels_range_arr[1] == -1): 1275 | self.album_levels = self.album_levels_range_arr[0] 1276 | self.album_levels_range_arr = () 1277 | logging.debug("album_levels is a range with negative start level and end level of -1, converted to album_levels = %d", self.album_levels) 1278 | else: 1279 | logging.debug("valid album_levels range argument supplied") 1280 | logging.debug("album_levels_start_level = %d", self.album_levels_range_arr[0]) 1281 | logging.debug("album_levels_end_level = %d",self.album_levels_range_arr[1]) 1282 | # Deduct 1 from album start levels, since album levels start at 1 for user convenience, but arrays start at index 0 1283 | if self.album_levels_range_arr[0] > 0: 1284 | self.album_levels_range_arr[0] -= 1 1285 | self.album_levels_range_arr[1] -= 1 1286 | 1287 | @staticmethod 1288 | def __glob_to_re(pattern: str) -> str: 1289 | """ 1290 | Converts the provided GLOB pattern to 1291 | a regular expression. 1292 | 1293 | :param pattern: A GLOB-style pattern to convert to a regular expression 1294 | 1295 | :returns: A regular expression matching the same strings as the provided GLOB pattern 1296 | :rtype: str 1297 | """ 1298 | return Configuration.__escaped_glob_replacement.sub(lambda match: Configuration.__escaped_glob_tokens_to_re[match.group(0)], regex.escape(pattern)) 1299 | 1300 | @staticmethod 1301 | def __expand_to_glob(expr: str) -> str: 1302 | """ 1303 | Expands the passed expression to a glob-style 1304 | expression if it doesn't contain neither a slash nor an asterisk. 1305 | The resulting glob-style expression matches any path that contains the 1306 | original expression anywhere. 1307 | 1308 | :param expr: Expression to expand to a GLOB-style expression if not already one 1309 | :returns: The original expression if it contained a slash or an asterisk, otherwise `**/**/**` 1310 | :rtype: str 1311 | """ 1312 | if not '/' in expr and not '*' in expr: 1313 | glob_expr = f'**/*{expr}*/**' 1314 | logging.debug("expanding %s to %s", expr, glob_expr) 1315 | return glob_expr 1316 | return expr 1317 | 1318 | @staticmethod 1319 | def get_arg_parser() -> argparse.ArgumentParser: 1320 | """ 1321 | Creates a the argument parser for parsing command line arguments. 1322 | 1323 | :returns: The ArgumentParser with all options the script supports 1324 | :rtype: argparse.ArgumentParser 1325 | """ 1326 | 1327 | parser = argparse.ArgumentParser(description="Create Immich Albums from an external library path based on the top level folders", 1328 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 1329 | parser.add_argument("root_path", action='append', help="The external library's root path in Immich") 1330 | parser.add_argument("api_url", help="The root API URL of immich, e.g. https://immich.mydomain.com/api/") 1331 | parser.add_argument("api_key", action='append', help="The Immich API Key to use. Set --api-key-type to 'file' if a file path is provided.") 1332 | parser.add_argument("--api-key", action="append", 1333 | help="Additional API Keys to run the script for; May be specified multiple times for running the script for multiple users.") 1334 | parser.add_argument("-t", "--api-key-type", default=Configuration.CONFIG_DEFAULTS['api_key_type'], choices=['literal', 'file'], help="The type of the Immich API Key") 1335 | parser.add_argument("-r", "--root-path", action="append", 1336 | help="Additional external library root path in Immich; May be specified multiple times for multiple import paths or external libraries.") 1337 | 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.") 1338 | parser.add_argument("-a", "--album-levels", default=Configuration.CONFIG_DEFAULTS['album_levels'], type=str, 1339 | help="""Number of sub-folders or range of sub-folder levels below the root path used for album name creation. 1340 | Positive numbers start from top of the folder structure, negative numbers from the bottom. Cannot be 0. 1341 | If a range should be set, the start level and end level must be separated by a comma like ','. 1342 | If negative levels are used in a range, must be less than or equal to .""") 1343 | parser.add_argument("-s", "--album-separator", default=Configuration.CONFIG_DEFAULTS['album_separator'], type=str, 1344 | help="Separator string to use for compound album names created from nested folders. Only effective if -a is set to a value > 1") 1345 | parser.add_argument("-R", "--album-name-post-regex", nargs='+', 1346 | action='append', 1347 | metavar=('PATTERN', 'REPL'), 1348 | help='Regex pattern and optional replacement (use "" for empty replacement). Can be specified multiple times.') 1349 | parser.add_argument("-c", "--chunk-size", default=Configuration.CONFIG_DEFAULTS['chunk_size'], type=int, help="Maximum number of assets to add to an album with a single API call") 1350 | parser.add_argument("-C", "--fetch-chunk-size", default=Configuration.CONFIG_DEFAULTS['fetch_chunk_size'], type=int, help="Maximum number of assets to fetch with a single API call") 1351 | parser.add_argument("-l", "--log-level", default=Configuration.CONFIG_DEFAULTS['log_level'], choices=['CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'], 1352 | help="Log level to use. ATTENTION: Log level DEBUG logs API key in clear text!") 1353 | parser.add_argument("-k", "--insecure", action="store_true", help="Pass to ignore SSL verification") 1354 | parser.add_argument("-i", "--ignore", action="append", 1355 | help="""Use either literals or glob-like patterns to ignore assets for album name creation. 1356 | This filter is evaluated after any values passed with --path-filter. May be specified multiple times.""") 1357 | parser.add_argument("-m", "--mode", default=Configuration.CONFIG_DEFAULTS['mode'], 1358 | choices=[Configuration.SCRIPT_MODE_CREATE, Configuration.SCRIPT_MODE_CLEANUP, Configuration.SCRIPT_MODE_DELETE_ALL], 1359 | help="""Mode for the script to run with. 1360 | CREATE = Create albums based on folder names and provided arguments; 1361 | CLEANUP = Create album names based on current images and script arguments, but delete albums if they exist; 1362 | DELETE_ALL = Delete all albums. 1363 | If the mode is anything but CREATE, --unattended does not have any effect. 1364 | Only performs deletion if -d/--delete-confirm option is set, otherwise only performs a dry-run.""") 1365 | parser.add_argument("-d", "--delete-confirm", action="store_true", 1366 | help="""Confirm deletion of albums when running in mode """+Configuration.SCRIPT_MODE_CLEANUP+""" or """+Configuration.SCRIPT_MODE_DELETE_ALL+""". 1367 | If this flag is not set, these modes will perform a dry run only. Has no effect in mode """+Configuration.SCRIPT_MODE_CREATE) 1368 | parser.add_argument("-x", "--share-with", action="append", 1369 | help="""A user name (or email address of an existing user) to share newly created albums with. 1370 | Sharing only happens if the album was actually created, not if new assets were added to an existing album. 1371 | If the the share role should be specified by user, the format = must be used, where must be one of 'viewer' or 'editor'. 1372 | May be specified multiple times to share albums with more than one user.""") 1373 | parser.add_argument("-o", "--share-role", default=Configuration.CONFIG_DEFAULTS['share_role'], choices=ApiClient.SHARE_ROLES, 1374 | help="""The default share role for users newly created albums are shared with. 1375 | Only effective if --share-with is specified at least once and the share role is not specified within --share-with.""") 1376 | parser.add_argument("-S", "--sync-mode", default=Configuration.CONFIG_DEFAULTS['sync_mode'], type=int, choices=[0, 1, 2], 1377 | help="""Synchronization mode to use. Synchronization mode helps synchronizing changes in external libraries structures to Immich after albums 1378 | have already been created. Possible Modes: 0 = do nothing; 1 = Delete any empty albums; 2 = Delete offline assets AND any empty albums""") 1379 | parser.add_argument("-O", "--album-order", default=Configuration.CONFIG_DEFAULTS['album_order'], type=str, choices=[False, 'asc', 'desc'], 1380 | help="Set sorting order for newly created albums to newest or oldest file first, Immich defaults to newest file first") 1381 | parser.add_argument("-A", "--find-assets-in-albums", action="store_true", 1382 | help="""By default, the script only finds assets that are not assigned to any album yet. 1383 | Set this option to make the script discover assets that are already part of an album and handle them as usual. 1384 | If --find-archived-assets is set as well, both options apply.""") 1385 | parser.add_argument("-f", "--path-filter", action="append", 1386 | help="""Use either literals or glob-like patterns to filter assets before album name creation. 1387 | This filter is evaluated before any values passed with --ignore. May be specified multiple times.""") 1388 | parser.add_argument("--set-album-thumbnail", choices=Configuration.ALBUM_THUMBNAIL_SETTINGS_GLOBAL, 1389 | help="""Set first/last/random image as thumbnail for newly created albums or albums assets have been added to. 1390 | If set to """+Configuration.ALBUM_THUMBNAIL_RANDOM_FILTERED+""", thumbnails are shuffled for all albums whose assets would not be 1391 | filtered out or ignored by the ignore or path-filter options, even if no assets were added during the run. 1392 | If set to """+Configuration.ALBUM_THUMBNAIL_RANDOM_ALL+""", the thumbnails for ALL albums will be shuffled on every run.""") 1393 | parser.add_argument("--visibility", choices=['archive', 'hidden', 'locked', 'timeline'], 1394 | help="""Set this option to automatically set the visibility of all assets that are discovered by the script and assigned to albums. 1395 | Exception for value 'locked': Assets will not be added to any albums, but to the 'locked' folder only. 1396 | Also applies if -m/--mode is set to CLEAN_UP or DELETE_ALL; then it affects all assets in the deleted albums. 1397 | Always overrides -v/--archive.""") 1398 | parser.add_argument("--find-archived-assets", action="store_true", 1399 | help="""By default, the script only finds assets with visibility set to 'timeline' (which is the default). 1400 | Set this option to make the script discover assets with visibility 'archive' as well. 1401 | If -A/--find-assets-in-albums is set as well, both options apply.""") 1402 | parser.add_argument("--read-album-properties", action="store_true", 1403 | help="""If set, the script tries to access all passed root paths and recursively search for .albumprops files in all contained folders. 1404 | These properties will be used to set custom options on an per-album level. Check the readme for a complete documentation.""") 1405 | parser.add_argument("--api-timeout", default=Configuration.CONFIG_DEFAULTS['api_timeout'], type=int, help="Timeout when requesting Immich API in seconds") 1406 | parser.add_argument("--comments-and-likes-enabled", action="store_true", 1407 | help="Pass this argument to enable comment and like functionality in all albums this script adds assets to. Cannot be used together with --comments-and-likes-disabled") 1408 | parser.add_argument("--comments-and-likes-disabled", action="store_true", 1409 | help="Pass this argument to disable comment and like functionality in all albums this script adds assets to. Cannot be used together with --comments-and-likes-enabled") 1410 | parser.add_argument("--update-album-props-mode", type=int, choices=[0, 1, 2], default=Configuration.CONFIG_DEFAULTS['update_album_props_mode'], 1411 | help="""Change how album properties are updated whenever new assets are added to an album. Album properties can either come from script arguments or the .albumprops file. 1412 | Possible values: 1413 | 0 = Do not change album properties. 1414 | 1 = Only override album properties but do not change the share status. 1415 | 2 = Override album properties and share status, this will remove all users from the album which are not in the SHARE_WITH list.""") 1416 | parser.add_argument("--max-retry-count", type=int, default=Configuration.CONFIG_DEFAULTS['max_retry_count'], 1417 | help="Number of times to retry an Immich API call if it timed out before failing.") 1418 | return parser 1419 | 1420 | @staticmethod 1421 | def init_global_config() -> None: 1422 | """ 1423 | Initializes global configuration options from global config file () 1424 | 1425 | :returns: All configurations the script should run with 1426 | :rtype: None 1427 | """ 1428 | parser = Configuration.get_arg_parser() 1429 | args = vars(parser.parse_args()) 1430 | Configuration.log_level = Utils.get_value_or_config_default("log_level", args, Configuration.CONFIG_DEFAULTS["log_level"]) 1431 | 1432 | @classmethod 1433 | def get_configurations(cls) -> list[Configuration]: 1434 | """ 1435 | Creates and returns a list of configuration objects from the script's arguments. 1436 | 1437 | :returns: All configurations the script should run with 1438 | :rtype: list[Configuration] 1439 | """ 1440 | parser = Configuration.get_arg_parser() 1441 | args = vars(parser.parse_args()) 1442 | created_configs: list[Configuration] = [] 1443 | api_key_type = Utils.get_value_or_config_default("api_key_type", args, Configuration.CONFIG_DEFAULTS["api_key_type"]) 1444 | # Create a configuration for each passed API key 1445 | for api_key_arg in args['api_key']: 1446 | api_keys = Configuration.__determine_api_key(api_key_arg, api_key_type) 1447 | config_args = args 1448 | for api_key in api_keys: 1449 | # replace the API key array with the current api key for that configuration args 1450 | config_args['api_key'] = api_key 1451 | created_configs.append(cls(config_args)) 1452 | # Return a list with a single configuration 1453 | return created_configs 1454 | 1455 | @staticmethod 1456 | def __determine_api_key(api_key_source: str, key_type: str) -> list[str]: 1457 | """ 1458 | For key_type `literal`, api_key_source is returned in a list with a single record. 1459 | For key_type `file`, api_key_source is a path to a file containing the API key, 1460 | each line in that file is interpreted as an API key. and a list with each line as a record 1461 | is returned. 1462 | 1463 | :param api_key_source: An API key or path to a file containing an API key 1464 | :param key_type: Must be either 'literal' or 'file' 1465 | 1466 | :returns: A list of API keys to use 1467 | :rtype: list[str] 1468 | """ 1469 | if key_type == 'literal': 1470 | return [api_key_source] 1471 | if key_type == 'file': 1472 | return Utils.read_file(api_key_source).splitlines() 1473 | # At this point key_type is not a valid value 1474 | logging.error("Unknown key type (-t, --key-type). Must be either 'literal' or 'file'.") 1475 | return None 1476 | 1477 | @staticmethod 1478 | def log_debug_global(): 1479 | """ 1480 | Logs global configuration options on `DEBUG` log level 1481 | """ 1482 | logging.debug("%s = '%s'", "log_level", Configuration.log_level) 1483 | 1484 | def log_debug(self): 1485 | """ 1486 | Logs all its own properties on `DEBUG` log level 1487 | """ 1488 | props = dict(vars(self)) 1489 | for prop in list(props.keys()): 1490 | logging.debug("%s = '%s'", prop, props[prop]) 1491 | 1492 | 1493 | class FolderAlbumCreator(): 1494 | """The Folder Album Creator class creating albums from folder structures based on the passed configuration""" 1495 | 1496 | # File name to use for album properties files 1497 | ALBUMPROPS_FILE_NAME = '.albumprops' 1498 | 1499 | def __init__(self, configuration : Configuration): 1500 | self.config = configuration 1501 | # Create API client for configuration 1502 | self.api_client = ApiClient(self.config.root_url, 1503 | self.config.api_key, 1504 | chunk_size=self.config.chunk_size, 1505 | fetch_chunk_size=self.config.fetch_chunk_size, 1506 | api_timeout=self.config.api_timeout, 1507 | insecure=self.config.insecure, 1508 | max_retry_count=self.config.max_retry_count) 1509 | 1510 | @staticmethod 1511 | def find_albumprops_files(paths: list[str]) -> list[str]: 1512 | 1513 | """ 1514 | Recursively finds all album properties files in all passed paths. 1515 | 1516 | :param paths: A list of paths to search for album properties files 1517 | 1518 | :returns: A list of paths with all album properties files 1519 | :rtype: list[str] 1520 | """ 1521 | albumprops_files = [] 1522 | for path in paths: 1523 | if not os.path.isdir(path): 1524 | logging.warning("Album Properties Discovery: Path %s does not exist!", path) 1525 | continue 1526 | for path_tuple in os.walk(path): 1527 | root = path_tuple[0] 1528 | filenames = path_tuple[2] 1529 | for filename in fnmatch.filter(filenames, FolderAlbumCreator.ALBUMPROPS_FILE_NAME): 1530 | albumprops_files.append(os.path.join(root, filename)) 1531 | return albumprops_files 1532 | 1533 | @staticmethod 1534 | def __identify_root_path(path: str, root_path_list: list[str]) -> str: 1535 | """ 1536 | Identifies which root path is the parent of the provided path. 1537 | 1538 | :param path: The path to find the root path for 1539 | :type path: str 1540 | :param root_path_list: The list of root paths to get the one path is a child of from 1541 | :type root_path_list: list[str] 1542 | :return: The root path from root_path_list that is the parent of path 1543 | :rtype: str 1544 | """ 1545 | for root_path in root_path_list: 1546 | if root_path in path: 1547 | return root_path 1548 | return None 1549 | 1550 | def build_album_properties_templates(self) -> dict: 1551 | """ 1552 | Searches all root paths for album properties files, 1553 | applies ignore/filtering mechanisms, parses the files, 1554 | creates AlbumModel objects from them, performs validations and returns 1555 | a dictionary mapping mapping the album name (generated from the path the album properties file was found in) 1556 | to the album model file. 1557 | If a fatal error occurs during processing of album properties files (i.e. two files encountered targeting the same album with incompatible properties), the 1558 | program exits. 1559 | 1560 | :returns: A dictionary mapping the album name (generated from the path the album properties file was found in) to the album model files 1561 | :rtype: dict 1562 | """ 1563 | fatal_error_occurred = False 1564 | album_properties_file_paths = FolderAlbumCreator.find_albumprops_files(self.config.root_paths) 1565 | # Dictionary mapping album name generated from album properties' path to the AlbumModel representing the 1566 | # album properties 1567 | album_props_templates = {} 1568 | album_name_to_album_properties_file_path = {} 1569 | for album_properties_file_path in album_properties_file_paths: 1570 | # First check global path_filter and ignore options 1571 | if self.is_path_ignored(album_properties_file_path): 1572 | continue 1573 | 1574 | # Identify the root path 1575 | album_props_root_path = FolderAlbumCreator.__identify_root_path(album_properties_file_path, self.config.root_paths) 1576 | if not album_props_root_path: 1577 | continue 1578 | 1579 | # Chunks of the asset's path below root_path 1580 | path_chunks = album_properties_file_path.replace(album_props_root_path, '').split('/') 1581 | # A single chunk means it's just the image file in no sub folder, ignore 1582 | if len(path_chunks) == 1: 1583 | continue 1584 | 1585 | # remove last item from path chunks, which is the file name 1586 | del path_chunks[-1] 1587 | album_name = self.create_album_name(path_chunks, self.config.album_level_separator, self.config.album_name_post_regex) 1588 | if album_name is None: 1589 | continue 1590 | try: 1591 | # Parse the album properties into an album model 1592 | album_props_template = AlbumModel.parse_album_properties_file(album_properties_file_path) 1593 | if not album_props_template: 1594 | logging.warning("Unable to parse album properties file %s", album_properties_file_path) 1595 | continue 1596 | 1597 | album_props_template.name = album_name 1598 | if not album_name in album_props_templates: 1599 | album_props_templates[album_name] = album_props_template 1600 | album_name_to_album_properties_file_path[album_name] = album_properties_file_path 1601 | # There is already an album properties template with the same album name (maybe from a different root_path) 1602 | else: 1603 | incompatible_props = album_props_template.find_incompatible_properties(album_props_templates[album_name]) 1604 | if len(incompatible_props) > 0: 1605 | logging.fatal("Album Properties files %s and %s create an album with identical name but have conflicting properties:", 1606 | album_name_to_album_properties_file_path[album_name], album_properties_file_path) 1607 | for incompatible_prop in incompatible_props: 1608 | logging.fatal(incompatible_prop) 1609 | fatal_error_occurred = True 1610 | 1611 | except yaml.YAMLError as ex: 1612 | logging.error("Could not parse album properties file %s: %s", album_properties_file_path, ex) 1613 | 1614 | if fatal_error_occurred: 1615 | raise AlbumModelValidationError("Encountered at least one fatal error during parsing or validating of album properties files!") 1616 | 1617 | # Now validate that all album properties templates with the same override_name are compatible with each other 1618 | FolderAlbumCreator.validate_album_props_templates(album_props_templates.values(), album_name_to_album_properties_file_path) 1619 | 1620 | return album_props_templates 1621 | 1622 | @staticmethod 1623 | def validate_album_props_templates(album_props_templates: list[AlbumModel], album_name_to_album_properties_file_path: dict): 1624 | """ 1625 | Validates the provided list of album properties. 1626 | Specifically, checks that if multiple album properties files specify the same override_name, all other specified properties 1627 | are the same as well. 1628 | 1629 | If a validation error occurs, the program exits. 1630 | 1631 | :param album_props_templates: The list of `AlbumModel` objects to validate 1632 | :param album_name_to_album_properties_file_path: A dictionary where the key is an album name and the value is the path to the album properties file the 1633 | album was generated from. This method expects one entry in this dictionary for every `AlbumModel` in album_props_templates. 1634 | 1635 | :raises AlbumMergeError: If validations do not pass 1636 | """ 1637 | fatal_error_occurred = False 1638 | # This is a cache to remember checked names - keep time complexity down 1639 | checked_override_names = [] 1640 | # Loop over all album properties templates 1641 | for album_props_template in album_props_templates: 1642 | # Check if override_name is set and not already checked 1643 | if album_props_template.override_name and album_props_template.override_name not in checked_override_names: 1644 | # Inner loop through album properties template 1645 | for album_props_template_to_check in album_props_templates: 1646 | # Do not check against ourselves and only check if the other template has the same override name (we already checked above that override_name is not None) 1647 | if (album_props_template is not album_props_template_to_check 1648 | and album_props_template.override_name == album_props_template_to_check.override_name): 1649 | if FolderAlbumCreator.check_for_and_log_incompatible_properties(album_props_template, album_props_template_to_check, album_name_to_album_properties_file_path): 1650 | fatal_error_occurred = True 1651 | checked_override_names.append(album_props_template.override_name) 1652 | 1653 | if fatal_error_occurred: 1654 | raise AlbumMergeError("Encountered at least one fatal error while validating album properties files, stopping!") 1655 | 1656 | @staticmethod 1657 | def check_for_and_log_incompatible_properties(model1: AlbumModel, model2: AlbumModel, album_name_to_album_properties_file_path: dict) -> bool: 1658 | """ 1659 | Checks if model1 and model2 have incompatible properties (same properties set to different values). If so, 1660 | logs the the incompatible properties and returns True. 1661 | 1662 | :param model1: The first album model to check for incompatibility with the second model 1663 | :param model2: The second album model to check for incompatibility with the first model 1664 | :param album_name_to_album_properties_file_path: A dictionary where the key is an album name and the value is the path to the album properties file the 1665 | album was generated from. This method expects one entry in this dictionary for every AlbumModel in album_props_templates 1666 | 1667 | :returns: False if model1 and model2 are compatible, otherwise True 1668 | :rtype: bool 1669 | """ 1670 | incompatible_props = model1.find_incompatible_properties(model2) 1671 | if len(incompatible_props) > 0: 1672 | logging.fatal("Album properties files %s and %s define the same override_name but have incompatible properties:", 1673 | album_name_to_album_properties_file_path[model1.name], 1674 | album_name_to_album_properties_file_path[model2.name]) 1675 | for incompatible_prop in incompatible_props: 1676 | logging.fatal(incompatible_prop) 1677 | return True 1678 | return False 1679 | 1680 | @staticmethod 1681 | def build_inheritance_chain_for_album_path(album_path: str, root_path: str, albumprops_cache_param: dict) -> list[AlbumModel]: 1682 | """ 1683 | Builds the inheritance chain for a given album path by walking up the directory tree 1684 | and finding all .albumprops files with inherit=True. 1685 | 1686 | :param album_path: The full path to the album directory 1687 | :param root_path: The root path to stop the inheritance chain at 1688 | :param albumprops_cache: Dictionary mapping .albumprops file paths to AlbumModel objects 1689 | 1690 | :returns: List of AlbumModel objects in inheritance order (root to current) 1691 | :rtype: list[AlbumModel] 1692 | """ 1693 | inheritance_chain = [] 1694 | current_path = os.path.normpath(album_path) 1695 | root_path = os.path.normpath(root_path) 1696 | 1697 | # Walk up the directory tree until we reach the root path 1698 | while len(current_path) >= len(root_path): 1699 | albumprops_path = os.path.join(current_path, FolderAlbumCreator.ALBUMPROPS_FILE_NAME) 1700 | 1701 | if albumprops_path in albumprops_cache_param: 1702 | album_model_local = albumprops_cache_param[albumprops_path] 1703 | inheritance_chain.insert(0, album_model_local) # Insert at beginning for correct order 1704 | 1705 | # If this level doesn't have inherit=True, stop the inheritance chain 1706 | if not album_model_local.inherit: 1707 | break 1708 | 1709 | # If we've reached the root path, stop 1710 | if current_path == root_path: 1711 | break 1712 | 1713 | # Move up one directory level 1714 | parent_path = os.path.dirname(current_path) 1715 | if parent_path == current_path: # Reached filesystem root 1716 | break 1717 | current_path = parent_path 1718 | 1719 | return inheritance_chain 1720 | 1721 | # pylint: disable=R0912 1722 | @staticmethod 1723 | def apply_inheritance_to_album_model(album_model_param: AlbumModel, inheritance_chain: list[AlbumModel]) -> AlbumModel: 1724 | """ 1725 | Applies inheritance from the inheritance chain to the given album model. 1726 | 1727 | :param album_model: The target album model to apply inheritance to (can be None if no local .albumprops) 1728 | :param inheritance_chain: List of AlbumModel objects in inheritance order (root to current, excluding current) 1729 | 1730 | :returns: The album model with inherited properties applied 1731 | :rtype: AlbumModel 1732 | """ 1733 | if not inheritance_chain: 1734 | return album_model_param 1735 | 1736 | # Create a new model to hold inherited properties 1737 | if album_model_param: 1738 | inherited_model: AlbumModel = AlbumModel(album_model_param.name) 1739 | # Copy all properties from the original model first 1740 | for prop_name in AlbumModel.ALBUM_PROPERTIES_VARIABLES + AlbumModel.ALBUM_INHERITANCE_VARIABLES: 1741 | setattr(inherited_model, prop_name, getattr(album_model_param, prop_name)) 1742 | inherited_model.id = album_model_param.id 1743 | inherited_model.assets = album_model_param.assets 1744 | else: 1745 | inherited_model = AlbumModel(None) 1746 | 1747 | inherited_share_with = [] 1748 | 1749 | # Apply inheritance from root to current (inheritance_chain is already in correct order) 1750 | for parent_model in inheritance_chain: 1751 | if not parent_model.inherit_properties: 1752 | # If no inherit_properties specified, inherit all properties 1753 | inherit_props = AlbumModel.ALBUM_PROPERTIES_VARIABLES 1754 | else: 1755 | inherit_props = parent_model.inherit_properties 1756 | 1757 | # Apply inherited properties 1758 | parent_attribs = vars(parent_model) 1759 | inherited_attribs = vars(inherited_model) 1760 | 1761 | for prop_name in inherit_props: 1762 | if prop_name in AlbumModel.ALBUM_PROPERTIES_VARIABLES and parent_attribs[prop_name] is not None: 1763 | if prop_name == 'share_with': 1764 | # Special handling for share_with - accumulate users 1765 | if parent_attribs[prop_name]: 1766 | inherited_share_with.extend(parent_attribs[prop_name]) 1767 | else: 1768 | # For other properties, only set if not already set (parent properties have lower precedence) 1769 | if inherited_attribs[prop_name] is None: 1770 | inherited_attribs[prop_name] = parent_attribs[prop_name] 1771 | 1772 | # Apply accumulated inherited share_with if the current model doesn't have share_with 1773 | if inherited_share_with and not inherited_model.share_with: 1774 | inherited_model.share_with = inherited_share_with 1775 | elif inherited_share_with and inherited_model.share_with: 1776 | # Merge inherited and current share_with using the special merge logic 1777 | temp_model = AlbumModel(None) 1778 | temp_model.share_with = inherited_share_with 1779 | inherited_model.share_with = inherited_model.merge_inherited_share_with(inherited_model.share_with) 1780 | 1781 | return inherited_model 1782 | 1783 | def build_albumprops_cache(self) -> dict: 1784 | """ 1785 | Builds a cache of all .albumprops files found in root paths. 1786 | 1787 | :returns: Dictionary mapping .albumprops file paths to AlbumModel objects 1788 | :rtype: dict 1789 | """ 1790 | albumprops_files = FolderAlbumCreator.find_albumprops_files(self.config.root_paths) 1791 | albumprops_path_to_model_dict = {} 1792 | 1793 | # Parse all .albumprops files 1794 | for albumprops_path in albumprops_files: 1795 | if self.is_path_ignored(albumprops_path): 1796 | continue 1797 | 1798 | try: 1799 | album_model_local = AlbumModel.parse_album_properties_file(albumprops_path) 1800 | if album_model_local: 1801 | albumprops_path_to_model_dict[albumprops_path] = album_model_local 1802 | logging.debug("Loaded .albumprops from %s", albumprops_path) 1803 | except yaml.YAMLError as ex: 1804 | logging.error("Could not parse album properties file %s: %s", albumprops_path, ex) 1805 | 1806 | return albumprops_path_to_model_dict 1807 | 1808 | @staticmethod 1809 | def get_album_properties_with_inheritance(album_name: str, album_path: str, root_path: str, albumprops_cache_param: dict) -> AlbumModel: 1810 | """ 1811 | Gets the album properties for an album, applying inheritance from parent folders. 1812 | 1813 | :param album_name: The name of the album 1814 | :param album_path: The full path to the album directory 1815 | :param root_path: The root path for this album 1816 | :param albumprops_cache: Dictionary mapping .albumprops file paths to AlbumModel objects 1817 | 1818 | :returns: The album model with inheritance applied, or None if no properties found 1819 | :rtype: AlbumModel 1820 | """ 1821 | # Check if the album directory has its own .albumprops file 1822 | albumprops_path = os.path.join(album_path, FolderAlbumCreator.ALBUMPROPS_FILE_NAME) 1823 | local_album_model = None 1824 | 1825 | logging.debug("Checking for album properties: album_path=%s, albumprops_path=%s", album_path, albumprops_path) 1826 | 1827 | if albumprops_path in albumprops_cache_param: 1828 | local_album_model = albumprops_cache_param[albumprops_path] 1829 | local_album_model.name = album_name 1830 | logging.debug("Found local .albumprops for album '%s'", album_name) 1831 | 1832 | # Build inheritance chain (excluding the current level) 1833 | inheritance_chain = FolderAlbumCreator.build_inheritance_chain_for_album_path(album_path, root_path, albumprops_cache_param) 1834 | 1835 | # Remove the current level from inheritance chain if it exists 1836 | if inheritance_chain and albumprops_path in albumprops_cache_param: 1837 | current_model = albumprops_cache_param[albumprops_path] 1838 | if inheritance_chain and inheritance_chain[-1] is current_model: 1839 | inheritance_chain = inheritance_chain[:-1] 1840 | 1841 | logging.debug("Inheritance chain for album '%s' has %d levels", album_name, len(inheritance_chain)) 1842 | 1843 | # Apply inheritance 1844 | if inheritance_chain or local_album_model: 1845 | final_model = FolderAlbumCreator.apply_inheritance_to_album_model(local_album_model, inheritance_chain) 1846 | if final_model and final_model.name is None: 1847 | final_model.name = album_name 1848 | 1849 | # Log final properties for this album 1850 | if final_model and (inheritance_chain or local_album_model): 1851 | logging.info("Final album properties for '%s' (with inheritance): %s", album_name, final_model) 1852 | 1853 | return final_model 1854 | 1855 | return None 1856 | 1857 | 1858 | @staticmethod 1859 | def __parse_separated_string(separated_string: str, separator: str) -> Tuple[str, str]: 1860 | """ 1861 | Parse a key, value pair, separated by the provided separator. 1862 | 1863 | That's the reverse of ShellArgs. 1864 | On the command line (argparse) a declaration will typically look like: 1865 | foo=hello 1866 | or 1867 | foo="hello world" 1868 | 1869 | :param separated_string: The string to parse 1870 | :param separator: The separator to parse separated_string at 1871 | :return: A tuple with the first value being the string on the left side of the separator 1872 | and the second value the string on the right side of the separator 1873 | """ 1874 | items = separated_string.split(separator) 1875 | key = items[0].strip() # we remove blanks around keys, as is logical 1876 | value = None 1877 | if len(items) > 1: 1878 | # rejoin the rest: 1879 | value = separator.join(items[1:]) 1880 | return (key, value) 1881 | 1882 | # pylint: disable=R0912 1883 | def create_album_name(self, asset_path_chunks: list[str], album_separator: str, album_name_postprocess_regex: list) -> str: 1884 | """ 1885 | Create album names from provided path_chunks string array. 1886 | 1887 | The method uses global variables album_levels_range_arr or album_levels to 1888 | generate album names either by level range or absolute album levels. If multiple 1889 | album path chunks are used for album names they are separated by album_separator. 1890 | :param asset_path_chunks: A list of strings representing the folder names parsed from an asset's path 1891 | :param album_seprator: The separator used for album names spanning multiple folder levels 1892 | :param album_name_postprocess_regex: List of pairs of regex and replace, optional 1893 | 1894 | :returns: The created album name or None if the album levels range does not apply to the path chunks. 1895 | :rtype: str 1896 | """ 1897 | 1898 | album_name_chunks = () 1899 | logging.debug("path chunks = %s", list(asset_path_chunks)) 1900 | # Check which path to take: album_levels_range or album_levels 1901 | if len(self.config.album_levels_range_arr) == 2: 1902 | if self.config.album_levels_range_arr[0] < 0: 1903 | album_levels_start_level_capped = min(len(asset_path_chunks), abs(self.config.album_levels_range_arr[0])) 1904 | album_levels_end_level_capped = self.config.album_levels_range_arr[1]+1 1905 | album_levels_start_level_capped *= -1 1906 | else: 1907 | # If our start range is already out of range of our path chunks, do not create an album from that. 1908 | if len(asset_path_chunks)-1 < self.config.album_levels_range_arr[0]: 1909 | logging.debug("Skipping asset chunks since out of range: %s", asset_path_chunks) 1910 | return None 1911 | album_levels_start_level_capped = min(len(asset_path_chunks)-1, self.config.album_levels_range_arr[0]) 1912 | # 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 1913 | # if the end index is out of bounds. 1914 | album_levels_end_level_capped = min(len(asset_path_chunks)-1, self.config.album_levels_range_arr[1]) + 1 1915 | logging.debug("album_levels_start_level_capped = %d", album_levels_start_level_capped) 1916 | logging.debug("album_levels_end_level_capped = %d", album_levels_end_level_capped) 1917 | # album start level is not equal to album end level, so we want a range of levels 1918 | if album_levels_start_level_capped is not album_levels_end_level_capped: 1919 | 1920 | # if the end index is out of bounds. 1921 | if album_levels_end_level_capped < 0 and abs(album_levels_end_level_capped) >= len(asset_path_chunks): 1922 | album_name_chunks = asset_path_chunks[album_levels_start_level_capped:] 1923 | else: 1924 | album_name_chunks = asset_path_chunks[album_levels_start_level_capped:album_levels_end_level_capped] 1925 | # album start and end levels are equal, we want exactly that level 1926 | else: 1927 | # create on-the-fly array with a single element taken from 1928 | album_name_chunks = [asset_path_chunks[album_levels_start_level_capped]] 1929 | else: 1930 | album_levels_int = int(self.config.album_levels) 1931 | # either use as many path chunks as we have, 1932 | # or the specified album levels 1933 | album_name_chunk_size = min(len(asset_path_chunks), abs(album_levels_int)) 1934 | if album_levels_int < 0: 1935 | album_name_chunk_size *= -1 1936 | 1937 | # Copy album name chunks from the path to use as album name 1938 | album_name_chunks = asset_path_chunks[:album_name_chunk_size] 1939 | if album_name_chunk_size < 0: 1940 | album_name_chunks = asset_path_chunks[album_name_chunk_size:] 1941 | logging.debug("album_name_chunks = %s", album_name_chunks) 1942 | 1943 | # final album name before regex 1944 | album_name = album_separator.join(album_name_chunks) 1945 | logging.debug("Album Name %s", album_name) 1946 | 1947 | # apply regex if any 1948 | if album_name_postprocess_regex: 1949 | for pattern, *repl in album_name_postprocess_regex: 1950 | # If no replacement string provided, default to empty string 1951 | replace = repl[0] if repl else '' 1952 | album_name = regex.sub(pattern, replace, album_name) 1953 | logging.debug("Album Post Regex s/%s/%s/g --> %s", pattern, replace, album_name) 1954 | 1955 | return album_name.strip() 1956 | 1957 | def choose_thumbnail(self, thumbnail_setting: str, thumbnail_asset_list: list[dict]) -> str: 1958 | """ 1959 | Tries to find an asset to use as thumbnail depending on thumbnail_setting. 1960 | 1961 | :param thumbnail_setting: Either a fully qualified asset path or one of `first`, `last`, `random`, `random-filtered` 1962 | :param asset_list: A list of assets to choose a thumbnail from, based on thumbnail_setting 1963 | 1964 | :returns: An Immich asset dict or None if no thumbnail was found based on thumbnail_setting 1965 | :rtype: str 1966 | """ 1967 | # Case: fully qualified path 1968 | if thumbnail_setting not in Configuration.ALBUM_THUMBNAIL_SETTINGS_GLOBAL: 1969 | for asset in thumbnail_asset_list: 1970 | if asset['originalPath'] == thumbnail_setting: 1971 | return asset 1972 | # at this point we could not find the thumbnail asset by path 1973 | return None 1974 | 1975 | # Case: Anything but fully qualified path 1976 | # Apply filtering to assets 1977 | thumbnail_assets = thumbnail_asset_list 1978 | if thumbnail_setting == Configuration.ALBUM_THUMBNAIL_RANDOM_FILTERED: 1979 | thumbnail_assets[:] = [asset for asset in thumbnail_assets if not self.is_path_ignored(asset['originalPath'])] 1980 | 1981 | if len(thumbnail_assets) > 0: 1982 | # Sort assets by creation date 1983 | thumbnail_assets.sort(key=lambda x: x['fileCreatedAt']) 1984 | if thumbnail_setting not in Configuration.ALBUM_THUMBNAIL_STATIC_INDICES: 1985 | idx = random.randint(0, len(thumbnail_assets)-1) 1986 | else: 1987 | idx = Configuration.ALBUM_THUMBNAIL_STATIC_INDICES[thumbnail_setting] 1988 | return thumbnail_assets[idx] 1989 | 1990 | # Case: Invalid thumbnail_setting 1991 | return None 1992 | 1993 | 1994 | def is_path_ignored(self, path_to_check: str) -> bool: 1995 | """ 1996 | Determines if the asset should be ignored for the purpose of this script 1997 | based in its originalPath and global ignore and path_filter options. 1998 | 1999 | :param asset_to_check: The asset to check if it must be ignored or not. Must have the key 'originalPath'. 2000 | 2001 | :returns: True if the asset must be ignored, otherwise False 2002 | :rtype: bool 2003 | """ 2004 | is_path_ignored_result = False 2005 | asset_root_path = None 2006 | for root_path_to_check in self.config.root_paths: 2007 | if root_path_to_check in path_to_check: 2008 | asset_root_path = root_path_to_check 2009 | break 2010 | logging.debug("Identified root_path for asset %s = %s", path_to_check, asset_root_path) 2011 | if asset_root_path: 2012 | # First apply filter, if any 2013 | if len(self.config.path_filter_regex) > 0: 2014 | any_match = False 2015 | for path_filter_regex_entry in self.config.path_filter_regex: 2016 | if regex.fullmatch(path_filter_regex_entry, path_to_check.replace(asset_root_path, '')): 2017 | any_match = True 2018 | if not any_match: 2019 | logging.debug("Ignoring path %s due to path_filter setting!", path_to_check) 2020 | is_path_ignored_result = True 2021 | # If the asset "survived" the path filter, check if it is in the ignore_albums argument 2022 | if not is_path_ignored_result and len(self.config.ignore_albums_regex) > 0: 2023 | for ignore_albums_regex_entry in self.config.ignore_albums_regex: 2024 | if regex.fullmatch(ignore_albums_regex_entry, path_to_check.replace(asset_root_path, '')): 2025 | is_path_ignored_result = True 2026 | logging.debug("Ignoring path %s due to ignore_albums setting!", path_to_check) 2027 | break 2028 | 2029 | return is_path_ignored_result 2030 | 2031 | @staticmethod 2032 | def get_album_id_by_name(albums_list: list[dict], album_name: str, ) -> str: 2033 | """ 2034 | Finds the album with the provided name in the list of albums and returns its id. 2035 | 2036 | :param albums_list: List of albums to find a particular album in 2037 | :param album_name: Name of album to find the ID for 2038 | 2039 | :returns: The ID of the requested album or None if not found 2040 | :rtype: str 2041 | """ 2042 | for _ in albums_list: 2043 | if _['albumName'] == album_name: 2044 | return _['id'] 2045 | return None 2046 | 2047 | def check_for_and_remove_live_photo_video_components(self, asset_list: list[dict], is_not_in_album: bool, find_archived: bool) -> list[dict]: 2048 | """ 2049 | Checks asset_list for any asset with file ending .mov. This is indicative of a possible video component 2050 | of an Apple Live Photo. There is display bug in the Immich iOS app that prevents live photos from being 2051 | show correctly if the static AND video component are added to the album. We only want to add the static component to an album, 2052 | so we need to filter out all video components belonging to a live photo. The static component has a property livePhotoVideoId set 2053 | with the asset ID of the video component. 2054 | 2055 | :param is_not_in_album: Flag indicating whether to fetch only assets that are not part 2056 | of an album or not. If this and find_archived are True, we can assume asset_list is complete 2057 | and should contain any static components. 2058 | :param find_archived: Flag indicating whether to only fetch assets that are archived. If this and is_not_in_album are 2059 | True, we can assume asset_list is complete and should contain any static components. 2060 | 2061 | :returns: An asset list without live photo video components 2062 | :rtype: list[dict] 2063 | """ 2064 | logging.info("Checking for live photo video components") 2065 | # Filter for all quicktime assets 2066 | asset_list_mov = [asset for asset in asset_list if 'video' in asset['originalMimeType']] 2067 | 2068 | if len(asset_list_mov) == 0: 2069 | logging.debug("No live photo video components found") 2070 | return asset_list 2071 | 2072 | # If either is not True, we need to fetch all assets 2073 | if is_not_in_album or not find_archived: 2074 | logging.debug("Fetching all assets for live photo video component check") 2075 | if self.api_client.server_version['major'] == 1 and self.api_client.server_version['minor'] < 133: 2076 | full_asset_list = self.api_client.fetch_assets_with_options({'isNotInAlbum': False, 'withArchived': True}) 2077 | else: 2078 | full_asset_list = self.api_client.fetch_assets_with_options({'isNotInAlbum': False}) 2079 | full_asset_list += self.api_client.fetch_assets_with_options({'isNotInAlbum': False, 'visibility': 'archive'}) 2080 | else: 2081 | full_asset_list = asset_list 2082 | 2083 | # Find all assets with a live ID set 2084 | asset_list_with_live_id = [asset for asset in full_asset_list if asset['livePhotoVideoId'] is not None] 2085 | 2086 | # Find all video components 2087 | asset_list_video_components_ids = [] 2088 | for asset_static_component in asset_list_with_live_id: 2089 | for asset_mov in asset_list_mov: 2090 | if asset_mov['id'] == asset_static_component['livePhotoVideoId']: 2091 | asset_list_video_components_ids.append(asset_mov['id']) 2092 | logging.debug("File %s is a video component of a live photo, removing from list", asset_mov['originalPath']) 2093 | 2094 | logging.info("Removing %s live photo video components from asset list", len(asset_list_video_components_ids)) 2095 | # Remove all video components from the asset list 2096 | asset_list_without_video_components = [asset for asset in asset_list if asset['id'] not in asset_list_video_components_ids] 2097 | return asset_list_without_video_components 2098 | 2099 | # pylint: disable=R0914 2100 | def set_album_properties_in_model(self, album_model_to_update: AlbumModel) -> None: 2101 | """ 2102 | Sets the album_model's properties based on script options set. 2103 | 2104 | :param album_model: The album model to set the properties for 2105 | """ 2106 | # Set share_with 2107 | if self.config.share_with: 2108 | for album_share_user in self.config.share_with: 2109 | # Resolve share user-specific share role syntax = 2110 | share_user_name, share_user_role_local = FolderAlbumCreator.__parse_separated_string(album_share_user, '=') 2111 | # Fallback to default 2112 | if share_user_role_local is None: 2113 | share_user_role_local = self.config.share_role 2114 | 2115 | album_share_with = { 2116 | 'user': share_user_name, 2117 | 'role': share_user_role_local 2118 | } 2119 | album_model_to_update.share_with.append(album_share_with) 2120 | 2121 | # Thumbnail Setting 2122 | if self.config.set_album_thumbnail: 2123 | album_model_to_update.thumbnail_setting = self.config.set_album_thumbnail 2124 | 2125 | # Archive setting 2126 | if self.config.visibility is not None: 2127 | album_model_to_update.visibility = self.config.visibility 2128 | 2129 | # Sort Order 2130 | if self.config.album_order: 2131 | album_model_to_update.sort_order = self.config.album_order 2132 | 2133 | # Comments and Likes 2134 | if self.config.comments_and_likes_enabled: 2135 | album_model_to_update.comments_and_likes_enabled = True 2136 | elif self.config.comments_and_likes_disabled: 2137 | album_model_to_update.comments_and_likes_enabled = False 2138 | 2139 | # pylint: disable=R0914,R1702,R0915 2140 | def build_album_list(self, asset_list : list[dict], root_path_list : list[str], album_props_templates: dict, albumprops_cache_param: dict = None) -> dict: 2141 | """ 2142 | Builds a list of album models, enriched with assets assigned to each album. 2143 | Returns a dict where the key is the album name and the value is the model. 2144 | Attention! 2145 | 2146 | :param asset_list: List of assets dictionaries fetched from Immich API 2147 | :param root_path_list: List of root paths to use for album creation 2148 | :param album_props_templates: Dictionary mapping an album name to album properties 2149 | :param albumprops_cache: Dictionary mapping .albumprops file paths to AlbumModel objects (for inheritance) 2150 | 2151 | :returns: A dict with album names as keys and an AlbumModel as value 2152 | :rtype: dict 2153 | """ 2154 | album_models = {} 2155 | logged_albums = set() # Track which albums we've already logged properties for 2156 | 2157 | for asset_to_add in asset_list: 2158 | asset_path = asset_to_add['originalPath'] 2159 | # This method will log the ignore reason, so no need to log anything again. 2160 | if self.is_path_ignored(asset_path): 2161 | continue 2162 | 2163 | # Identify the root path 2164 | asset_root_path = FolderAlbumCreator.__identify_root_path(asset_path, root_path_list) 2165 | if not asset_root_path: 2166 | continue 2167 | 2168 | # Chunks of the asset's path below root_path 2169 | path_chunks = asset_path.replace(asset_root_path, '').split('/') 2170 | # A single chunk means it's just the image file in no sub folder, ignore 2171 | if len(path_chunks) == 1: 2172 | continue 2173 | 2174 | # remove last item from path chunks, which is the file name 2175 | del path_chunks[-1] 2176 | album_name = self.create_album_name(path_chunks, self.config.album_level_separator, self.config.album_name_post_regex) 2177 | # Silently skip album, create_album_name already did debug logging 2178 | if album_name is None: 2179 | continue 2180 | 2181 | if len(album_name) > 0: 2182 | # Check if we already created this album model 2183 | final_album_name = album_name 2184 | 2185 | # Check for album properties with inheritance if albumprops_cache is provided 2186 | inherited_album_model = None 2187 | if albumprops_cache_param: 2188 | # Reconstruct the full album directory path 2189 | album_dir_path = os.path.join(asset_root_path, *path_chunks) 2190 | inherited_album_model = FolderAlbumCreator.get_album_properties_with_inheritance(album_name, album_dir_path, asset_root_path, albumprops_cache_param) 2191 | if inherited_album_model and inherited_album_model.override_name: 2192 | final_album_name = inherited_album_model.override_name 2193 | 2194 | # Check if there are traditional album properties for this album (backward compatibility) 2195 | album_props_template = album_props_templates.get(album_name) 2196 | if album_props_template and album_props_template.override_name: 2197 | final_album_name = album_props_template.override_name 2198 | 2199 | # Check if we already have this album model 2200 | if final_album_name in album_models: 2201 | new_album_model = album_models[final_album_name] 2202 | 2203 | # Merge properties when adding assets to an existing album with same final name 2204 | if inherited_album_model: 2205 | # For share_with, we need to accumulate all users from all directories 2206 | # using the most restrictive role policy 2207 | if inherited_album_model.share_with: 2208 | # Create a temporary model to merge the new share_with settings 2209 | # with the already accumulated settings in the existing album 2210 | temp_merge_model = AlbumModel(new_album_model.name) 2211 | temp_merge_model.share_with = new_album_model.share_with if new_album_model.share_with else [] 2212 | 2213 | # Use the merge logic to properly handle user conflicts, roles, and 'none' users 2214 | new_album_model.share_with = temp_merge_model.merge_inherited_share_with(inherited_album_model.share_with) 2215 | 2216 | # For other properties, only update if not already set (preserve first album's properties as base) 2217 | temp_model = AlbumModel(new_album_model.name) 2218 | # Copy only non-share_with properties to avoid overwriting our accumulated share_with 2219 | for prop_name in AlbumModel.ALBUM_PROPERTIES_VARIABLES: 2220 | if prop_name != 'share_with' and getattr(inherited_album_model, prop_name) is not None: 2221 | setattr(temp_model, prop_name, getattr(inherited_album_model, prop_name)) 2222 | new_album_model.merge_from(temp_model, AlbumModel.ALBUM_MERGE_MODE_EXCLUSIVE) 2223 | 2224 | else: 2225 | new_album_model = AlbumModel(album_name) 2226 | # Apply album properties from set options 2227 | self.set_album_properties_in_model(new_album_model) 2228 | 2229 | # Apply inherited properties if available 2230 | if inherited_album_model: 2231 | new_album_model.merge_from(inherited_album_model, AlbumModel.ALBUM_MERGE_MODE_OVERRIDE) 2232 | 2233 | # Log final album properties (only once per album) 2234 | if final_album_name not in logged_albums: 2235 | logging.info("Final album properties for '%s' (with inheritance): %s", final_album_name, new_album_model) 2236 | logged_albums.add(final_album_name) 2237 | 2238 | # Apply traditional album properties if available (backward compatibility) 2239 | elif album_props_template: 2240 | new_album_model.merge_from(album_props_template, AlbumModel.ALBUM_MERGE_MODE_OVERRIDE) 2241 | 2242 | # Log final album properties (only once per album) 2243 | if final_album_name not in logged_albums: 2244 | logging.info("Final album properties for '%s': %s", final_album_name, new_album_model) 2245 | logged_albums.add(final_album_name) 2246 | else: 2247 | # Log final album properties for albums without .albumprops (only once per album) 2248 | if final_album_name not in logged_albums: 2249 | logging.info("Final album properties for '%s': %s", final_album_name, new_album_model) 2250 | logged_albums.add(final_album_name) 2251 | 2252 | # Add asset to album model 2253 | new_album_model.assets.append(asset_to_add) 2254 | album_models[new_album_model.get_final_name()] = new_album_model 2255 | else: 2256 | logging.warning("Got empty album name for asset path %s, check your album_level settings!", asset_path) 2257 | return album_models 2258 | 2259 | # pylint: disable=R1705 2260 | @staticmethod 2261 | def find_user_by_name_or_email(name_or_email: str, user_list: list[dict]) -> dict: 2262 | """ 2263 | Finds a user identified by name_or_email in the provided user_list. 2264 | 2265 | :param name_or_email: The user name or email address to find the user by 2266 | :param user_list: A list of user dictionaries with the following mandatory keys: `id`, `name`, `email` 2267 | 2268 | :returns: A user dict with matching name or email or None if no matching user was found 2269 | :rtype: dict 2270 | """ 2271 | for user in user_list: 2272 | # Search by name or mail address 2273 | if name_or_email in (user['name'], user['email']): 2274 | return user 2275 | return None 2276 | 2277 | def run(self) -> None: 2278 | """ 2279 | Performs the actual logic of the script, i.e. creating albums from external library folder structures and everything else. 2280 | """ 2281 | # Special case: Run Mode DELETE_ALL albums 2282 | if self.config.mode == Configuration.SCRIPT_MODE_DELETE_ALL: 2283 | self.api_client.delete_all_albums(self.config.visibility, self.config.delete_confirm) 2284 | return 2285 | 2286 | album_properties_templates = {} 2287 | albumprops_cache = {} 2288 | if self.config.read_album_properties: 2289 | logging.debug("Albumprops: Finding, parsing and loading %s files with inheritance support", FolderAlbumCreator.ALBUMPROPS_FILE_NAME) 2290 | albumprops_cache = self.build_albumprops_cache() 2291 | # Keep the old templates for backward compatibility with existing logic that expects album name keys 2292 | album_properties_templates = self.build_album_properties_templates() 2293 | for album_properties_path, album_properties_template in album_properties_templates.items(): 2294 | logging.debug("Albumprops: %s -> %s", album_properties_path, album_properties_template) 2295 | 2296 | logging.info("Requesting all assets") 2297 | # only request images that are not in any album if we are running in CREATE mode, 2298 | # otherwise we need all images, even if they are part of an album 2299 | if self.config.mode == Configuration.SCRIPT_MODE_CREATE: 2300 | assets = self.api_client.fetch_assets(not self.config.find_assets_in_albums, ['archive'] if self.config.find_archived_assets else []) 2301 | else: 2302 | assets = self.api_client.fetch_assets(False, ['archive']) 2303 | 2304 | # Remove live photo video components 2305 | assets = self.check_for_and_remove_live_photo_video_components(assets, not self.config.find_assets_in_albums, self.config.find_archived_assets) 2306 | logging.info("%d photos found", len(assets)) 2307 | 2308 | logging.info("Sorting assets to corresponding albums using folder name") 2309 | albums_to_create = self.build_album_list(assets, self.config.root_paths, album_properties_templates, albumprops_cache) 2310 | albums_to_create = dict(sorted(albums_to_create.items(), key=lambda item: item[0])) 2311 | 2312 | if self.api_client.server_version['major'] == 1 and self.api_client.server_version['minor'] < 133: 2313 | albums_with_visibility = [album_check_to_check for album_check_to_check in albums_to_create.values() 2314 | if album_check_to_check.visibility is not None and album_check_to_check.visibility != 'archive'] 2315 | if len(albums_with_visibility) > 0: 2316 | logging.warning("Option 'visibility' is only supported in Immich Server v1.133.x and newer! Option will be ignored!") 2317 | 2318 | logging.info("%d albums identified", len(albums_to_create)) 2319 | logging.info("Album list: %s", list(albums_to_create.keys())) 2320 | 2321 | if not self.config.unattended and self.config.mode == Configuration.SCRIPT_MODE_CREATE: 2322 | if is_docker: 2323 | print("Check that this is the list of albums you want to create. Run the container with environment variable UNATTENDED set to 1 to actually create these albums.") 2324 | return 2325 | else: 2326 | print("Press enter to create these albums, Ctrl+C to abort") 2327 | input() 2328 | 2329 | logging.info("Listing existing albums on immich") 2330 | 2331 | albums = self.api_client.fetch_albums() 2332 | logging.info("%d existing albums identified", len(albums)) 2333 | 2334 | album: AlbumModel 2335 | for album in albums_to_create.values(): 2336 | # fetch the id if same album name exist 2337 | album.id = FolderAlbumCreator.get_album_id_by_name(albums, album.get_final_name()) 2338 | 2339 | # mode CLEANUP 2340 | if self.config.mode == Configuration.SCRIPT_MODE_CLEANUP: 2341 | # Filter list of albums to create for existing albums only 2342 | albums_to_cleanup = {} 2343 | for album in albums_to_create.values(): 2344 | # Only cleanup existing albums (has id set) and no duplicates (due to override_name) 2345 | if album.id and album.id not in albums_to_cleanup: 2346 | albums_to_cleanup[album.id] = album 2347 | # pylint: disable=C0103 2348 | number_of_deleted_albums = self.api_client.cleanup_albums(albums_to_cleanup.values(), self.config.visibility, self.config.delete_confirm) 2349 | logging.info("Deleted %d/%d albums", number_of_deleted_albums, len(albums_to_cleanup)) 2350 | return 2351 | 2352 | # Get all users in preparation for album sharing 2353 | users = self.api_client.fetch_users() 2354 | logging.debug("Found users: %s", users) 2355 | 2356 | # mode CREATE 2357 | logging.info("Create / Append to Albums") 2358 | created_albums = [] 2359 | # List for gathering all asset UUIDs for later archiving 2360 | asset_uuids_added = [] 2361 | for album in albums_to_create.values(): 2362 | # Special case: Add assets to Locked folder 2363 | # Locked assets cannot be part of an album, so don't create albums in the first place 2364 | if album.visibility == 'locked': 2365 | self.api_client.set_assets_visibility(album.get_asset_uuids(), album.visibility) 2366 | logging.info("Added %d assets to locked folder", len(album.get_asset_uuids())) 2367 | continue 2368 | 2369 | # Create album if inexistent: 2370 | if not album.id: 2371 | album.id = self.api_client.create_album(album.get_final_name()) 2372 | created_albums.append(album) 2373 | logging.info('Album %s added!', album.get_final_name()) 2374 | 2375 | logging.info("Adding assets to album %s", album.get_final_name()) 2376 | assets_added = self.api_client.add_assets_to_album(album.id, album.get_asset_uuids()) 2377 | if len(assets_added) > 0: 2378 | asset_uuids_added += assets_added 2379 | logging.info("%d new assets added to %s", len(assets_added), album.get_final_name()) 2380 | 2381 | # Set assets visibility 2382 | if album.visibility is not None: 2383 | self.api_client.set_assets_visibility(assets_added, album.visibility) 2384 | logging.info("Set visibility for %d assets to %s", len(assets_added), album.visibility) 2385 | 2386 | # Update album properties depending on mode or if newly created 2387 | if self.config.update_album_props_mode > 0 or (album in created_albums): 2388 | # Handle thumbnail 2389 | # Thumbnail setting 'random-all' is handled separately 2390 | if album.thumbnail_setting and album.thumbnail_setting != Configuration.ALBUM_THUMBNAIL_RANDOM_ALL: 2391 | # Fetch assets to be sure to have up-to-date asset list 2392 | album_to_update_info = self.api_client.fetch_album_info(album.id) 2393 | album_assets = album_to_update_info['assets'] 2394 | thumbnail_asset = self.choose_thumbnail(album.thumbnail_setting, album_assets) 2395 | if thumbnail_asset: 2396 | logging.info("Using asset %s as thumbnail for album %s", thumbnail_asset['originalPath'], album.get_final_name()) 2397 | album.thumbnail_asset_uuid = thumbnail_asset['id'] 2398 | else: 2399 | logging.warning("Unable to determine thumbnail for setting '%s' in album %s", album.thumbnail_setting, album.get_final_name()) 2400 | # Update album properties 2401 | try: 2402 | self.api_client.update_album_properties(album) 2403 | except HTTPError as e: 2404 | logging.error('Error updating properties for album %s: %s', album.get_final_name(), e) 2405 | 2406 | # Update album sharing if needed or newly created 2407 | if self.config.update_album_props_mode == 2 or (album in created_albums): 2408 | # Handle album sharing 2409 | self.api_client.update_album_shared_state(album, True, users) 2410 | 2411 | logging.info("%d albums created", len(created_albums)) 2412 | 2413 | # Perform album cover randomization 2414 | if self.config.set_album_thumbnail == Configuration.ALBUM_THUMBNAIL_RANDOM_ALL: 2415 | logging.info("Picking a new random thumbnail for all albums") 2416 | albums = self.api_client.fetch_albums() 2417 | for album in albums: 2418 | album_info = self.api_client.fetch_album_info(album['id']) 2419 | # Create album model for thumbnail randomization 2420 | album_model = AlbumModel(album['albumName']) 2421 | album_model.id = album['id'] 2422 | album_model.assets = album_info['assets'] 2423 | # Set thumbnail setting to 'random' in model 2424 | album_model.thumbnail_setting = 'random' 2425 | thumbnail_asset = self.choose_thumbnail(album_model.thumbnail_setting, album_model.assets) 2426 | if thumbnail_asset: 2427 | logging.info("Using asset %s as thumbnail for album %s", thumbnail_asset['originalPath'], album.get_final_name()) 2428 | album_model.thumbnail_asset_uuid = thumbnail_asset['id'] 2429 | else: 2430 | logging.warning("Unable to determine thumbnail for setting '%s' in album %s", album.thumbnail_setting, album.get_final_name()) 2431 | # Update album properties (which will only pick a random thumbnail and set it, no other properties are changed) 2432 | self.api_client.update_album_properties(album_model) 2433 | 2434 | # Perform sync mode action: Trigger offline asset removal 2435 | if self.config.sync_mode == 2: 2436 | logging.info("Trigger offline asset removal") 2437 | self.api_client.trigger_offline_asset_removal() 2438 | 2439 | # Perform sync mode action: Delete empty albums 2440 | # 2441 | # For Immich versions prior to v1.116.0: 2442 | # Attention: Since Offline Asset Removal is an asynchronous job, 2443 | # albums affected by it are most likely not empty yet! So this 2444 | # might only be effective in the next script run. 2445 | if self.config.sync_mode >= 1: 2446 | logging.info("Deleting all empty albums") 2447 | albums = self.api_client.fetch_albums() 2448 | # pylint: disable=C0103 2449 | empty_album_count = 0 2450 | # pylint: disable=C0103 2451 | cleaned_album_count = 0 2452 | for album in albums: 2453 | if album['assetCount'] == 0: 2454 | empty_album_count += 1 2455 | logging.info("Deleting empty album %s", album['albumName']) 2456 | if self.api_client.delete_album(album): 2457 | cleaned_album_count += 1 2458 | if empty_album_count > 0: 2459 | logging.info("Successfully deleted %d/%d empty albums!", cleaned_album_count, empty_album_count) 2460 | else: 2461 | logging.info("No empty albums found!") 2462 | 2463 | logging.info("Done!") 2464 | 2465 | class Utils: 2466 | """A collection of helper methods""" 2467 | @staticmethod 2468 | def divide_chunks(full_list: list, chunk_size: int): 2469 | """ 2470 | Yield successive chunk_size-sized chunks from full_list. 2471 | 2472 | :param full_list: The full list to create chunks from 2473 | :param chunk_size: The number of records per chunk 2474 | """ 2475 | # looping till length l 2476 | for j in range(0, len(full_list), chunk_size): 2477 | yield full_list[j:j + chunk_size] 2478 | 2479 | @staticmethod 2480 | def assert_not_none_or_empty(key: str, value : any): 2481 | """ 2482 | Asserts that the passed value is not None and not empty 2483 | 2484 | :param value: The value to assert 2485 | :raises: ValueError if the passed value is None or empty 2486 | """ 2487 | if value is None or len(str(value)) == 0: 2488 | raise ValueError("Value for "+key+" is None or empty") 2489 | 2490 | @staticmethod 2491 | def get_value_or_config_default(key : str, args_dict : dict, default: any) -> any: 2492 | """ 2493 | Returns the value stored in args_dict under the provided key if it is not None or empty, 2494 | otherwise returns the value from Configuration.CONFIG_DEAULTS using the same key. 2495 | 2496 | :param default: The default value to return if value did not pass the check 2497 | :param key: The dictionary key to look up the value for 2498 | 2499 | :returns: The passed value or the configuration default if the value did not pass the checks 2500 | :rtype: any 2501 | """ 2502 | try: 2503 | Utils.assert_not_none_or_empty(key, args_dict[key] if key in args_dict else None) 2504 | return args_dict[key] 2505 | except ValueError: 2506 | return default 2507 | 2508 | @staticmethod 2509 | def is_integer(string_to_test: str) -> bool: 2510 | """ 2511 | Trying to deal with python's isnumeric() function 2512 | not recognizing negative numbers, tests whether the provided 2513 | string is an integer or not. 2514 | 2515 | :param string_to_test: The string to test for integer 2516 | 2517 | :returns: True if string_to_test is an integer, otherwise False 2518 | :rtype: bool 2519 | """ 2520 | try: 2521 | int(string_to_test) 2522 | return True 2523 | except ValueError: 2524 | return False 2525 | 2526 | @staticmethod 2527 | def read_file(file_path: str, encoding: str = "utf-8") -> str: 2528 | """ 2529 | Reads and returns the contents of the provided file. 2530 | Assumes 2531 | 2532 | :param file_path: Path to the file to read 2533 | :param encoding: The encoding to read the file with, defaults to `utf-8` 2534 | 2535 | :returns: The file's contents 2536 | :rtype: str 2537 | 2538 | :raises: FileNotFoundError if the file does not exist 2539 | :raises: Exception on any other error reading the file 2540 | """ 2541 | with open(file_path, 'r', encoding=encoding) as secret_file: 2542 | return secret_file.read().strip() 2543 | 2544 | class AlbumCreatorLogFormatter(logging.Formatter): 2545 | """Log formatter logging as logfmt with seconds-precision timestamps and lower-case log levels to match supercronic's logging""" 2546 | 2547 | def init_formatter(self, is_debug : bool) -> None: 2548 | """ 2549 | Initializes the log formatter, respecting the passed is_debug flag. 2550 | 2551 | :param is_debug: When true, sets the time format timestamp to 'milliseconds', otherwise to 'seconds'. 2552 | """ 2553 | timespec_setting = "seconds" 2554 | if is_debug: 2555 | timespec_setting = "milliseconds" 2556 | 2557 | logging.Formatter.formatTime = ( 2558 | lambda self, 2559 | record, 2560 | datefmt=None: datetime.datetime.fromtimestamp(record.created, datetime.timezone.utc).astimezone().replace(microsecond=0).isoformat(sep="T",timespec=timespec_setting).replace('+00:00', 'Z') 2561 | ) 2562 | 2563 | def format(self, record): 2564 | record.levelname = record.levelname.lower() 2565 | return logging.Formatter.format(self, record) 2566 | 2567 | # Set up logging 2568 | handler = logging.StreamHandler() 2569 | formatter = AlbumCreatorLogFormatter('time="%(asctime)s" level=%(levelname)s msg="%(message)s"') 2570 | formatter.init_formatter(False) 2571 | handler.setFormatter(formatter) 2572 | # Initialize logging with default log level, we might have to log something when initializing the global configuration (which includes the log level we should use) 2573 | logging.basicConfig(level=Configuration.CONFIG_DEFAULTS["log_level"], handlers=[handler]) 2574 | # Initialize global config 2575 | Configuration.init_global_config() 2576 | # Update log level with the level this configuration dictates 2577 | logging.getLogger().setLevel(Configuration.log_level) 2578 | if 'DEBUG' == Configuration.log_level: 2579 | formatter.init_formatter(True) 2580 | 2581 | 2582 | is_docker = os.environ.get(ENV_IS_DOCKER, False) 2583 | 2584 | try: 2585 | configs = Configuration.get_configurations() 2586 | logging.info("Created %d configurations", len(configs)) 2587 | except(HTTPError, ValueError, AssertionError) as e: 2588 | logging.fatal(e.msg) 2589 | sys.exit(1) 2590 | 2591 | Configuration.log_debug_global() 2592 | 2593 | for config in configs: 2594 | try: 2595 | folder_album_creator = FolderAlbumCreator(config) 2596 | 2597 | processing_api_key = folder_album_creator.api_client.api_key[:5] + '*' * (len(folder_album_creator.api_client.api_key)-5) 2598 | # Log the full API key when DEBUG logging is enabled 2599 | if 'DEBUG' == config.log_level: 2600 | processing_api_key = folder_album_creator.api_client.api_key 2601 | 2602 | logging.info("Processing API Key %s", processing_api_key) 2603 | # Log config to DEBUG level 2604 | config.log_debug() 2605 | folder_album_creator.run() 2606 | except (AlbumMergeError, AlbumModelValidationError, HTTPError, ValueError, AssertionError) as e: 2607 | logging.fatal("Fatal error while processing configuration!") 2608 | logging.fatal(e) 2609 | --------------------------------------------------------------------------------