[TASK] Save images that are not in albums to "Misc {{year}}" folders

This commit is contained in:
2025-05-29 21:15:17 +02:00
parent 1e2e7dfb5a
commit 86ad92e1aa
3 changed files with 92 additions and 29 deletions

View File

@@ -4,7 +4,7 @@ Script to export immich data as a structured folder. Useful if you want to brows
## To-do ## To-do
- [ ] 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 - [ ] Move (or rename?) photos that are deleted in Immich but exist on the local disk
- [ ] Dockerize the tool so it's easy to run on the server - [ ] 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)

View File

@@ -1,10 +1,18 @@
import os import os
import re import re
import time import argparse
from dotenv import load_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
# Parse command line arguments
parser = argparse.ArgumentParser(description='Export albums from Immich to local storage')
parser.add_argument('--dry-run', action='store_true', help='Print actions without writing files')
args = parser.parse_args()
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'] },
@@ -12,8 +20,11 @@ configuration = openapi_client.Configuration(
# 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): if not os.path.exists(target_dir) and not args.dry_run:
os.makedirs(target_dir) os.makedirs(target_dir)
print(f"Created target directory: {target_dir}")
elif not os.path.exists(target_dir) and args.dry_run:
print(f"[DRY RUN] Would create target directory: {target_dir}")
# Function to sanitize filenames # Function to sanitize filenames
def sanitize_filename(filename): def sanitize_filename(filename):
@@ -26,6 +37,38 @@ def sanitize_filename(filename):
sanitized = "unnamed" sanitized = "unnamed"
return sanitized return sanitized
def save_asset(asset, asset_dir):
# Create filename from original filename or asset ID if not available
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
# Skip if file already exists
if os.path.exists(asset_path):
print(f" Skipping existing file: {sanitized_asset_filename}")
return
# Save the asset to the album folder
if not args.dry_run:
# Download the asset
asset_data = assets_api.download_asset(asset.id)
with open(asset_path, 'wb') as f:
f.write(asset_data)
print(f" Saved asset to: {asset_path}")
else:
print(f" [DRY RUN] Would save asset to: {asset_path}")
return asset_path
album_count = 0
copied_assets = set()
asset_paths = set()
# Enter a context with an instance of the API client # Enter a context with an instance of the API client
with openapi_client.ApiClient(configuration) as immich_client: with openapi_client.ApiClient(configuration) as immich_client:
# Create instances of the API classes # Create instances of the API classes
@@ -36,16 +79,20 @@ with openapi_client.ApiClient(configuration) as immich_client:
all_albums = albums_api.get_all_albums() all_albums = albums_api.get_all_albums()
print(f"Found {len(all_albums)} albums") print(f"Found {len(all_albums)} albums")
# Limit to 5 albums for debugging # # Limit to 5 albums for debugging
albums_to_process = all_albums[:5] # albums_to_process = all_albums[:5]
print(f"Processing {len(albums_to_process)} albums for debugging") # print(f"Processing {len(albums_to_process)} albums for debugging")
albums_to_process = all_albums
for album in albums_to_process: for album in albums_to_process:
sanitized_album_name = sanitize_filename(album.album_name) sanitized_album_name = sanitize_filename(album.album_name)
album_path = os.path.join(target_dir, sanitized_album_name) album_path = os.path.join(target_dir, sanitized_album_name)
if not os.path.exists(album_path): if not os.path.exists(album_path):
os.makedirs(album_path) if not args.dry_run:
print(f"Created directory for album: {sanitized_album_name}") os.makedirs(album_path)
print(f"Created directory for album: {sanitized_album_name}")
else:
print(f"[DRY RUN] Would create directory for album: {sanitized_album_name}")
try: try:
# Get detailed album info with assets # Get detailed album info with assets
@@ -55,41 +102,56 @@ with openapi_client.ApiClient(configuration) as immich_client:
# Download each asset in the album # Download each asset in the album
for asset in album_info.assets: for asset in album_info.assets:
try: try:
# Create filename from original filename or asset ID if not available save_asset(asset, album_path)
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(album_path, sanitized_asset_filename)
# Skip if file already exists
if os.path.exists(asset_path):
print(f" Skipping existing file: {sanitized_asset_filename}")
continue
print(f" Downloading asset: {sanitized_asset_filename}")
# Download the asset
asset_data = assets_api.download_asset(asset.id)
# Save the asset to the album folder
with open(asset_path, 'wb') as f:
f.write(asset_data)
print(f" Saved asset to: {asset_path}")
# 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)
except ApiException as e: except ApiException as e:
print(f" Error downloading asset {asset.id}: {e}") print(f" Error downloading asset {asset.id}: {e}")
except Exception as e: except Exception as e:
print(f" Unexpected error downloading asset {asset.id}: {e}") print(f" Unexpected error downloading asset {asset.id}: {e}")
album_count += 1
except ApiException as e: except ApiException as e:
print(f"Error getting album info for {album.id}: {e}") print(f"Error getting album info for {album.id}: {e}")
except Exception as e: except Exception as e:
print(f"Unexpected error processing album {album.id}: {e}") print(f"Unexpected error processing album {album.id}: {e}")
print(f"Processing assets not in albums...")
search_api = openapi_client.SearchApi(immich_client)
search_query = openapi_client.MetadataSearchDto(
isNotInAlbum=True
)
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:
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"
if not os.path.exists(year_folder_path):
if not args.dry_run:
os.makedirs(year_folder_path)
print(f"Created directory for asset: {asset.id}")
save_asset(asset, year_folder_path)
copied_assets.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)
assets_not_in_albums = search_api.search_assets(search_query)
else:
assets_not_in_albums = None
except ApiException as e: except ApiException as e:
print(f"Exception while retrieving albums: {e}") print(f"Exception while retrieving albums: {e}")
except Exception as e: except Exception as e:
print(f"Unexpected error: {e}") print(f"Unexpected error: {e}")
print(f"Finished processing {album_count} albums with {len(copied_assets)} assets")

View File

@@ -1,4 +1,5 @@
urllib3 >= 1.25.3, < 3.0.0 urllib3 >= 2.2.2, < 3.0.0
python-dateutil >= 2.8.2 python-dateutil >= 2.8.2
pydantic >= 2 pydantic >= 2
typing-extensions >= 4.7.1 typing-extensions >= 4.7.1
dotenv >= 0.9.9