[TASK] Save images that are not in albums to "Misc {{year}}" folders
This commit is contained in:
@@ -4,7 +4,7 @@ Script to export immich data as a structured folder. Useful if you want to brows
|
||||
|
||||
## 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
|
||||
- [ ] Dockerize the tool so it's easy to run on the server
|
||||
- [ ] Stretch: Preserve metadata (e.g. file creation times)
|
||||
|
||||
112
immich-export.py
112
immich-export.py
@@ -1,10 +1,18 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import argparse
|
||||
from dotenv import load_dotenv
|
||||
|
||||
import generated.immich.openapi_client as openapi_client
|
||||
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(
|
||||
host = os.environ['IMMICH_HOST'],
|
||||
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
|
||||
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)
|
||||
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
|
||||
def sanitize_filename(filename):
|
||||
@@ -26,6 +37,38 @@ def sanitize_filename(filename):
|
||||
sanitized = "unnamed"
|
||||
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
|
||||
with openapi_client.ApiClient(configuration) as immich_client:
|
||||
# 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()
|
||||
print(f"Found {len(all_albums)} albums")
|
||||
|
||||
# Limit to 5 albums for debugging
|
||||
albums_to_process = all_albums[:5]
|
||||
print(f"Processing {len(albums_to_process)} albums for debugging")
|
||||
# # Limit to 5 albums for debugging
|
||||
# albums_to_process = all_albums[:5]
|
||||
# print(f"Processing {len(albums_to_process)} albums for debugging")
|
||||
albums_to_process = all_albums
|
||||
|
||||
for album in albums_to_process:
|
||||
sanitized_album_name = sanitize_filename(album.album_name)
|
||||
album_path = os.path.join(target_dir, sanitized_album_name)
|
||||
if not os.path.exists(album_path):
|
||||
if not args.dry_run:
|
||||
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:
|
||||
# Get detailed album info with assets
|
||||
@@ -55,41 +102,56 @@ with openapi_client.ApiClient(configuration) as immich_client:
|
||||
# Download each asset in the album
|
||||
for asset in album_info.assets:
|
||||
try:
|
||||
# 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(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}")
|
||||
save_asset(asset, album_path)
|
||||
|
||||
# Add a small delay to avoid overwhelming the server
|
||||
# time.sleep(0.5)
|
||||
|
||||
copied_assets.add(asset.id)
|
||||
|
||||
except ApiException as e:
|
||||
print(f" Error downloading asset {asset.id}: {e}")
|
||||
except Exception as e:
|
||||
print(f" Unexpected error downloading asset {asset.id}: {e}")
|
||||
|
||||
album_count += 1
|
||||
|
||||
except ApiException as e:
|
||||
print(f"Error getting album info for {album.id}: {e}")
|
||||
except Exception as 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:
|
||||
print(f"Exception while retrieving albums: {e}")
|
||||
except Exception as e:
|
||||
print(f"Unexpected error: {e}")
|
||||
|
||||
|
||||
|
||||
print(f"Finished processing {album_count} albums with {len(copied_assets)} assets")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
urllib3 >= 1.25.3, < 3.0.0
|
||||
urllib3 >= 2.2.2, < 3.0.0
|
||||
python-dateutil >= 2.8.2
|
||||
pydantic >= 2
|
||||
typing-extensions >= 4.7.1
|
||||
dotenv >= 0.9.9
|
||||
|
||||
Reference in New Issue
Block a user