[TASK] Delete assets that are deleted in Immich but exist on the local disk

This commit is contained in:
2025-05-29 21:48:27 +02:00
parent 7be7d32d58
commit 2882d45602
3 changed files with 51 additions and 13 deletions

View File

@@ -2,3 +2,6 @@ IMMICH_HOST=http://localhost:2283/api
IMMICH_API_KEY= IMMICH_API_KEY=
TARGET_DIR=./export 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=

View File

@@ -5,7 +5,7 @@ Script to export immich data as a structured folder. Useful if you want to brows
## To-do ## To-do
- [x] Create folders like "Misc {{year}}" for photos that are not in albums - [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 - [x] Dockerize the tool so it's easy to run on the server
- [ ] Stretch: Preserve metadata (e.g. file creation times) - [ ] Stretch: Preserve metadata (e.g. file creation times)
- [ ] Stretch: directly sync to Proton Drive (skipping the local filesystem) - [ ] Stretch: directly sync to Proton Drive (skipping the local filesystem)

View File

@@ -1,7 +1,8 @@
import os import os
import re import re
import argparse import argparse
from dotenv import load_dotenv
import dotenv
import generated.immich.openapi_client as openapi_client import generated.immich.openapi_client as openapi_client
from generated.immich.openapi_client.rest import ApiException 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') parser.add_argument('--dry-run', action='store_true', help='Print actions without writing files')
args = parser.parse_args() args = parser.parse_args()
load_dotenv() dotenv.load_dotenv()
configuration = openapi_client.Configuration( configuration = openapi_client.Configuration(
host = os.environ['IMMICH_HOST'], host = os.environ['IMMICH_HOST'],
api_key = { 'api_key': os.environ['IMMICH_API_KEY'] }, 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 # Set target directory from env var or use default
target_dir = os.getenv('TARGET_DIR', './export') target_dir = os.getenv('TARGET_DIR', './export')
if not os.path.exists(target_dir) and not args.dry_run: 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" asset_filename = asset.original_file_name if asset.original_file_name else f"{asset.id}.jpg"
sanitized_asset_filename = sanitize_filename(asset_filename) sanitized_asset_filename = sanitize_filename(asset_filename)
asset_path = os.path.join(asset_dir, sanitized_asset_filename) asset_path = os.path.join(asset_dir, sanitized_asset_filename)
count = 1 if asset_path in asset_paths:
while asset_path in asset_paths: asset_filename_without_extension = os.path.splitext(sanitized_asset_filename)[0]
# Duplicate filename, add a number to the end asset_extension = os.path.splitext(sanitized_asset_filename)[1]
asset_path = os.path.join(asset_dir, f"{sanitized_asset_filename} ({count})") count = 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 # Skip if file already exists
if os.path.exists(asset_path): if os.path.exists(asset_path):
@@ -66,7 +77,7 @@ def save_asset(asset, asset_dir):
return asset_path return asset_path
album_count = 0 album_count = 0
copied_assets = set() copied_asset_ids = set()
asset_paths = set() asset_paths = set()
# Enter a context with an instance of the API client # 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 # Add a small delay to avoid overwhelming the server
# time.sleep(0.5) # time.sleep(0.5)
copied_assets.add(asset.id) copied_asset_ids.add(asset.id)
except ApiException as e: except ApiException as e:
print(f" Error downloading asset {asset.id}: {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) assets_not_in_albums = search_api.search_assets(search_query)
while assets_not_in_albums: while assets_not_in_albums:
for asset in assets_not_in_albums.assets.items: for asset in assets_not_in_albums.assets.items:
if asset.id in copied_assets: if asset.id in copied_asset_ids:
continue continue
# Create a folder for the asset's year (i.e. Misc {{year}}) if it does not exist yet # 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" 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}") print(f"Created directory for asset: {asset.id}")
save_asset(asset, year_folder_path) 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: if assets_not_in_albums.assets.next_page is not None:
search_query.page = int(assets_not_in_albums.assets.next_page) 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: except Exception as e:
print(f"Unexpected error: {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")