[TASK] Delete assets that are deleted in Immich but exist on the local disk
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user