diff --git a/.env.example b/.env.example index 422a564..1704d71 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,6 @@ IMMICH_HOST=http://localhost:2283/api IMMICH_API_KEY= TARGET_DIR=./export + +# File extensions passed in this array will be deleted by the script if they're no longer found in immich +# MANAGED_FILE_TYPES= diff --git a/README.md b/README.md index f759d6a..04f96a9 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Script to export immich data as a structured folder. Useful if you want to brows ## To-do - [x] Create folders like "Misc {{year}}" for photos that are not in albums -- [ ] Move (or rename?) photos that are deleted in Immich but exist on the local disk +- [x] Delete assets that are deleted in Immich but exist on the local disk - [x] Dockerize the tool so it's easy to run on the server - [ ] Stretch: Preserve metadata (e.g. file creation times) - [ ] Stretch: directly sync to Proton Drive (skipping the local filesystem) diff --git a/immich-export.py b/immich-export.py index 936fcc2..02d2703 100644 --- a/immich-export.py +++ b/immich-export.py @@ -1,7 +1,8 @@ import os import re import argparse -from dotenv import load_dotenv + +import dotenv import generated.immich.openapi_client as openapi_client from generated.immich.openapi_client.rest import ApiException @@ -11,13 +12,18 @@ parser = argparse.ArgumentParser(description='Export albums from Immich to local parser.add_argument('--dry-run', action='store_true', help='Print actions without writing files') args = parser.parse_args() -load_dotenv() +dotenv.load_dotenv() configuration = openapi_client.Configuration( host = os.environ['IMMICH_HOST'], api_key = { 'api_key': os.environ['IMMICH_API_KEY'] }, ) +managed_file_type_str = os.environ.get('MANAGED_FILE_TYPES', 'jpg,jpeg,png,gif,bmp,webp,tif,tiff,wmv,mp4,mkv,dng,flv,mpg,3gp,mov,avi,webm,m4v,wmv') +managed_file_types = set(managed_file_type_str.split(',')) + +delete_threshold = os.environ.get('DELETE_THRESHOLD', 0.2) + # Set target directory from env var or use default target_dir = os.getenv('TARGET_DIR', './export') if not os.path.exists(target_dir) and not args.dry_run: @@ -42,11 +48,16 @@ def save_asset(asset, asset_dir): asset_filename = asset.original_file_name if asset.original_file_name else f"{asset.id}.jpg" sanitized_asset_filename = sanitize_filename(asset_filename) asset_path = os.path.join(asset_dir, sanitized_asset_filename) - count = 1 - while asset_path in asset_paths: - # Duplicate filename, add a number to the end - asset_path = os.path.join(asset_dir, f"{sanitized_asset_filename} ({count})") - count += 1 + if asset_path in asset_paths: + asset_filename_without_extension = os.path.splitext(sanitized_asset_filename)[0] + asset_extension = os.path.splitext(sanitized_asset_filename)[1] + count = 1 + while asset_path in asset_paths: + # Duplicate filename, add a number to the end + asset_path = os.path.join(asset_dir, f"{asset_filename_without_extension} ({count}){asset_extension}") + count += 1 + + asset_paths.add(asset_path) # Skip if file already exists if os.path.exists(asset_path): @@ -66,7 +77,7 @@ def save_asset(asset, asset_dir): return asset_path album_count = 0 -copied_assets = set() +copied_asset_ids = set() asset_paths = set() # Enter a context with an instance of the API client @@ -107,7 +118,7 @@ with openapi_client.ApiClient(configuration) as immich_client: # Add a small delay to avoid overwhelming the server # time.sleep(0.5) - copied_assets.add(asset.id) + copied_asset_ids.add(asset.id) except ApiException as e: print(f" Error downloading asset {asset.id}: {e}") @@ -129,7 +140,7 @@ with openapi_client.ApiClient(configuration) as immich_client: assets_not_in_albums = search_api.search_assets(search_query) while assets_not_in_albums: for asset in assets_not_in_albums.assets.items: - if asset.id in copied_assets: + if asset.id in copied_asset_ids: continue # Create a folder for the asset's year (i.e. Misc {{year}}) if it does not exist yet year_folder_path = os.path.join(target_dir, f"Misc {asset.local_date_time.year}") if asset.local_date_time else "Misc" @@ -139,7 +150,7 @@ with openapi_client.ApiClient(configuration) as immich_client: print(f"Created directory for asset: {asset.id}") save_asset(asset, year_folder_path) - copied_assets.add(asset.id) + copied_asset_ids.add(asset.id) if assets_not_in_albums.assets.next_page is not None: search_query.page = int(assets_not_in_albums.assets.next_page) @@ -152,6 +163,30 @@ with openapi_client.ApiClient(configuration) as immich_client: except Exception as e: print(f"Unexpected error: {e}") +all_extensions = set(map(lambda x: os.path.splitext(x)[1].lower(), asset_paths)) +print(f"Found extensions: {list(all_extensions)}") +# Now we check for files in the export directory that we no longer found in Immich, and delete those +to_delete = [] +for root, dirs, files in os.walk(target_dir): + for file in files: + # Check if the file is in one of the whitelisted file types + extension = os.path.splitext(file)[1].lower()[1:] + if not extension in managed_file_types: + continue + file_path = os.path.join(root, file) + if file_path not in asset_paths: + to_delete.append(file_path) -print(f"Finished processing {album_count} albums with {len(copied_assets)} assets") +# Note that if more than delete_threshold times the total number of assets are deleted, we won't delete any files, as there is probably a bug somewhere +if len(to_delete) > delete_threshold * len(asset_paths): + raise Exception(f"Found {len(to_delete)} files that are no longer in Immich. This seems to be a large number. No files will be deleted.") +else: + for file_path in to_delete: + if not args.dry_run: + os.remove(file_path) + print(f"Deleted file: {file_path}") + else: + print(f"Would delete file: {file_path}") + +print(f"Finished processing {album_count} albums with {len(copied_asset_ids)} assets")