#!/usr/bin/env python3 """ Import Lightroom Catalog into Directus This script reads a Lightroom Classic catalog (.lrcat file) and imports photos with their metadata into Directus. Usage: python import-lightroom.py /path/to/catalog.lrcat /path/to/photos/ Requirements: - Python 3.8+ - Lightroom catalog file (.lrcat) - Access to the photo files """ import argparse import json import os import re import sqlite3 import ssl import sys import urllib.request import urllib.error from pathlib import Path from datetime import datetime # Directus configuration DIRECTUS_URL = os.environ.get('DIRECTUS_URL', 'https://katheryn-cms.jeffemmett.com') DIRECTUS_EMAIL = 'katheryn@katheryntrenshaw.com' DIRECTUS_PASSWORD = '9BHhUaHSQjefuAEaPqPyjerf' # SSL context SSL_CONTEXT = ssl.create_default_context() SSL_CONTEXT.check_hostname = False SSL_CONTEXT.verify_mode = ssl.CERT_NONE TOKEN = None def get_token(): """Get Directus access token""" global TOKEN if TOKEN: return TOKEN data = json.dumps({ 'email': DIRECTUS_EMAIL, 'password': DIRECTUS_PASSWORD }).encode() req = urllib.request.Request( f'{DIRECTUS_URL}/auth/login', data=data, headers={ 'Content-Type': 'application/json', 'User-Agent': 'LightroomImport/1.0' } ) with urllib.request.urlopen(req, context=SSL_CONTEXT) as resp: result = json.loads(resp.read()) TOKEN = result['data']['access_token'] return TOKEN def api_request(method, endpoint, data=None): """Make authenticated API request""" token = get_token() url = f'{DIRECTUS_URL}{endpoint}' req = urllib.request.Request(url, method=method) req.add_header('Authorization', f'Bearer {token}') req.add_header('Content-Type', 'application/json') req.add_header('User-Agent', 'LightroomImport/1.0') if data: req.data = json.dumps(data).encode() try: with urllib.request.urlopen(req, context=SSL_CONTEXT) as resp: return json.loads(resp.read()) except urllib.error.HTTPError as e: error_body = e.read().decode() try: error = json.loads(error_body) return {'error': error.get('errors', [{}])[0].get('message', str(e))} except: return {'error': str(e)} def upload_file(file_path, title=None): """Upload a file to Directus""" import mimetypes import uuid token = get_token() url = f'{DIRECTUS_URL}/files' # Determine content type content_type, _ = mimetypes.guess_type(file_path) if not content_type: content_type = 'application/octet-stream' filename = os.path.basename(file_path) # Create multipart form data manually boundary = f'----WebKitFormBoundary{uuid.uuid4().hex[:16]}' # Read file content with open(file_path, 'rb') as f: file_content = f.read() # Build multipart body body_parts = [] # Add title field if provided if title: body_parts.append( f'--{boundary}\r\n' f'Content-Disposition: form-data; name="title"\r\n\r\n' f'{title}\r\n' ) # Add file field body_parts.append( f'--{boundary}\r\n' f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' f'Content-Type: {content_type}\r\n\r\n' ) # Combine parts body = ''.join(body_parts).encode() + file_content + f'\r\n--{boundary}--\r\n'.encode() req = urllib.request.Request(url, data=body, method='POST') req.add_header('Authorization', f'Bearer {token}') req.add_header('Content-Type', f'multipart/form-data; boundary={boundary}') req.add_header('User-Agent', 'LightroomImport/1.0') try: with urllib.request.urlopen(req, context=SSL_CONTEXT) as resp: result = json.loads(resp.read()) return result.get('data', {}).get('id') except urllib.error.HTTPError as e: print(f" Upload error: {e}") return None def read_lightroom_catalog(catalog_path): """Read metadata from Lightroom catalog (.lrcat SQLite database)""" if not os.path.exists(catalog_path): print(f"Error: Catalog file not found: {catalog_path}") sys.exit(1) conn = sqlite3.connect(catalog_path) conn.row_factory = sqlite3.Row cursor = conn.cursor() # Get list of tables to understand the schema cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") tables = [row[0] for row in cursor.fetchall()] print(f" Found {len(tables)} tables in catalog") photos = [] # Main photo query - Lightroom stores photos in Adobe_images # with metadata in various related tables try: cursor.execute(""" SELECT i.id_local as lr_id, i.id_global as lr_uuid, r.baseName as filename, r.extension as extension, f.pathFromRoot as folder_path, rf.absolutePath as root_path, i.captureTime as capture_time, i.rating as rating, i.colorLabels as color_label, i.pick as pick_status FROM Adobe_images i LEFT JOIN AgLibraryFile r ON i.rootFile = r.id_local LEFT JOIN AgLibraryFolder f ON r.folder = f.id_local LEFT JOIN AgLibraryRootFolder rf ON f.rootFolder = rf.id_local WHERE r.baseName IS NOT NULL """) for row in cursor.fetchall(): photo = dict(row) # Get IPTC/XMP metadata try: cursor.execute(""" SELECT caption, copyright, title FROM AgLibraryIPTC WHERE image = ? """, (photo['lr_id'],)) iptc = cursor.fetchone() if iptc: photo['caption'] = iptc['caption'] photo['copyright'] = iptc['copyright'] photo['title'] = iptc['title'] except: pass # Get keywords try: cursor.execute(""" SELECT k.name FROM AgLibraryKeyword k JOIN AgLibraryKeywordImage ki ON k.id_local = ki.tag WHERE ki.image = ? """, (photo['lr_id'],)) keywords = [row[0] for row in cursor.fetchall()] photo['keywords'] = keywords except: photo['keywords'] = [] # Get collections try: cursor.execute(""" SELECT c.name FROM AgLibraryCollection c JOIN AgLibraryCollectionImage ci ON c.id_local = ci.collection WHERE ci.image = ? """, (photo['lr_id'],)) collections = [row[0] for row in cursor.fetchall()] photo['collections'] = collections except: photo['collections'] = [] photos.append(photo) except sqlite3.OperationalError as e: print(f" Database query error: {e}") print(" This catalog may use a different schema version.") print(" Attempting alternative query...") # Try alternative schema (older Lightroom versions) try: cursor.execute(""" SELECT * FROM Adobe_images LIMIT 5 """) sample = cursor.fetchall() if sample: print(f" Found {len(sample)} sample records") # Print column names for debugging print(f" Columns: {[d[0] for d in cursor.description]}") except Exception as e2: print(f" Alternative query also failed: {e2}") conn.close() return photos def find_photo_file(photo, photos_dir): """Find the actual photo file on disk""" if not photo.get('filename'): return None filename = photo['filename'] extension = photo.get('extension', '') if extension and not filename.endswith(f'.{extension}'): filename = f"{filename}.{extension}" # Try various paths search_paths = [ # Direct in photos_dir os.path.join(photos_dir, filename), # With folder path from catalog os.path.join(photos_dir, photo.get('folder_path', ''), filename) if photo.get('folder_path') else None, # Full path from catalog os.path.join(photo.get('root_path', ''), photo.get('folder_path', ''), filename) if photo.get('root_path') else None, ] for path in search_paths: if path and os.path.exists(path): return path # Recursive search as fallback for root, dirs, files in os.walk(photos_dir): if filename in files: return os.path.join(root, filename) return None def import_photo_to_directus(photo, file_path): """Import a single photo to Directus""" # Upload the image file title = photo.get('title') or photo.get('filename', 'Untitled') file_id = upload_file(file_path, title=title) if not file_id: return None # Create artwork record artwork_data = { 'title': title, 'image': file_id, 'description': photo.get('caption'), 'lightroom_id': photo.get('lr_uuid'), 'status': 'draft', } # Parse year from capture time if photo.get('capture_time'): try: dt = datetime.fromisoformat(photo['capture_time'].replace('Z', '+00:00')) artwork_data['year'] = dt.year except: pass # Add keywords as tags (would need tag creation logic) # For now, store in description if photo.get('keywords'): keywords_str = ', '.join(photo['keywords']) if artwork_data.get('description'): artwork_data['description'] += f'\n\nKeywords: {keywords_str}' else: artwork_data['description'] = f'Keywords: {keywords_str}' # Remove None values artwork_data = {k: v for k, v in artwork_data.items() if v is not None} result = api_request('POST', '/items/artworks', artwork_data) if 'error' in result: print(f" Error creating artwork: {result['error']}") return None return result.get('data') def main(): parser = argparse.ArgumentParser(description='Import Lightroom catalog to Directus') parser.add_argument('catalog', help='Path to Lightroom catalog (.lrcat file)') parser.add_argument('photos_dir', help='Path to photos directory') parser.add_argument('--dry-run', action='store_true', help='Preview without importing') parser.add_argument('--limit', type=int, help='Limit number of photos to import') args = parser.parse_args() print("=" * 60) print(" LIGHTROOM TO DIRECTUS IMPORT") print("=" * 60) print(f"\nCatalog: {args.catalog}") print(f"Photos: {args.photos_dir}") print(f"Directus: {DIRECTUS_URL}") if args.dry_run: print("\n*** DRY RUN - No changes will be made ***\n") # Read catalog print("\n=== Reading Lightroom Catalog ===") photos = read_lightroom_catalog(args.catalog) print(f" Found {len(photos)} photos in catalog") if args.limit: photos = photos[:args.limit] print(f" Limited to {len(photos)} photos") if not photos: print("\nNo photos found to import.") return # Preview some photos print("\n=== Sample Photos ===") for photo in photos[:5]: print(f" - {photo.get('filename', 'Unknown')}") if photo.get('title'): print(f" Title: {photo['title']}") if photo.get('keywords'): print(f" Keywords: {', '.join(photo['keywords'][:5])}") if photo.get('collections'): print(f" Collections: {', '.join(photo['collections'][:3])}") if args.dry_run: print("\n=== Dry Run Summary ===") print(f"Would import {len(photos)} photos") return # Authenticate print("\n=== Authenticating ===") try: get_token() print(" Authenticated successfully!") except Exception as e: print(f" Authentication failed: {e}") sys.exit(1) # Import photos print("\n=== Importing Photos ===") imported = 0 skipped = 0 errors = 0 for i, photo in enumerate(photos, 1): filename = photo.get('filename', 'Unknown') print(f"[{i}/{len(photos)}] {filename}") # Find file on disk file_path = find_photo_file(photo, args.photos_dir) if not file_path: print(f" Skipped: File not found") skipped += 1 continue # Import to Directus result = import_photo_to_directus(photo, file_path) if result: imported += 1 print(f" Imported: {result.get('title', filename)}") else: errors += 1 print(f" Error: Failed to import") print("\n" + "=" * 60) print(f" IMPORT COMPLETE") print(f" Imported: {imported}") print(f" Skipped: {skipped}") print(f" Errors: {errors}") print("=" * 60) print(f"\nView at: {DIRECTUS_URL}/admin") if __name__ == '__main__': main()