• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jmathai / elodie / b4369c2d-f808-435c-b20c-cc2b679f2528

14 Jan 2026 12:04AM UTC coverage: 70.45% (-19.7%) from 90.128%
b4369c2d-f808-435c-b20c-cc2b679f2528

Pull #498

circleci

jmathai
Change path for elodie photos
Pull Request #498: Add Immich plugin to use Immich as a bidirectional UI for Elodie (gh-496)

127 of 616 new or added lines in 3 files covered. (20.62%)

1533 of 2176 relevant lines covered (70.45%)

0.7 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

15.71
/elodie/plugins/immich/immich.py
1
"""
2
Immich plugin for albums and favorites sync.
3
Enables albums and favorites to be managed through Immich's UI while ensuring:
4
- All metadata is persisted in the photo itself  
5
- Elodie remains the canonical organizer
6
- File moves do not break album or favorite state
7

8
.. moduleauthor:: Jaisen Mathai <jaisen@jmathai.com>
9
"""
10
from __future__ import print_function
1✔
11

12
import json
1✔
13
import os
1✔
14
import requests
1✔
15
import time
1✔
16
from datetime import datetime, timedelta
1✔
17
from os.path import basename, dirname, isfile
1✔
18
from typing import Dict, List, Optional, Set, Tuple
1✔
19

20
from elodie import constants
1✔
21
from elodie.media.photo import Photo
1✔
22
from elodie.media.video import Video  
1✔
23
from elodie.media.base import Base, get_all_subclasses
1✔
24
from elodie.plugins.plugins import PluginBase
1✔
25
from elodie.filesystem import FileSystem
1✔
26

27
class ImmichApiClient:
1✔
28
    """Client for interacting with Immich API.
29
    
30
    Provides methods for album management, asset search, and metadata operations.
31
    All methods handle authentication and error handling consistently.
32
    """
33
    
34
    # API endpoints
35
    ENDPOINTS = {
1✔
36
        'albums': '/albums',
37
        'search_metadata': '/search/metadata',
38
        'assets': '/assets'
39
    }
40
    
41
    def __init__(self, api_url, api_key):
1✔
42
        self.api_url = api_url.rstrip('/')
1✔
43
        self.api_key = api_key
1✔
44
        self.session = self._create_session(api_key)
1✔
45
    
46
    def _create_session(self, api_key):
1✔
47
        """Create and configure HTTP session"""
48
        session = requests.Session()
1✔
49
        session.headers.update({
1✔
50
            'X-API-Key': api_key,
51
            'Content-Type': 'application/json'
52
        })
53
        return session
1✔
54
    
55
    def _make_request(self, method, endpoint, **kwargs):
1✔
56
        """Make HTTP request with consistent error handling"""
NEW
57
        url = f"{self.api_url}{endpoint}"
×
NEW
58
        try:
×
NEW
59
            response = getattr(self.session, method.lower())(url, **kwargs)
×
NEW
60
            response.raise_for_status()
×
NEW
61
            return response
×
NEW
62
        except requests.RequestException as e:
×
NEW
63
            raise Exception(f"Failed {method.upper()} {endpoint}: {e}")
×
64

65

66
    # Album operations
67
    def get_all_albums(self):
1✔
68
        """Get all albums from Immich"""
NEW
69
        response = self._make_request('GET', self.ENDPOINTS['albums'])
×
NEW
70
        return response.json()
×
71
    
72
    def get_album_by_id(self, album_id):
1✔
73
        """Get a specific album by ID with its assets"""
NEW
74
        response = self._make_request('GET', f"{self.ENDPOINTS['albums']}/{album_id}")
×
NEW
75
        return response.json()
×
76

77
    def create_album(self, album_name, description=""):
1✔
78
        """Create a new album in Immich"""
79
        if constants.dry_run:
1✔
80
            print(f"[DRY-RUN][Immich] Would create album: {album_name}")
1✔
81
            return {'id': 'dry-run-album-id', 'albumName': album_name}
1✔
82
            
NEW
83
        payload = {
×
84
            'albumName': album_name,
85
            'description': description
86
        }
NEW
87
        response = self._make_request('POST', self.ENDPOINTS['albums'], json=payload)
×
NEW
88
        return response.json()
×
89

90
    def add_assets_to_album(self, album_id, asset_ids):
1✔
91
        """Add assets to an album"""
92
        if constants.dry_run:
1✔
93
            print(f"[DRY-RUN][Immich] Would add {len(asset_ids)} assets to album {album_id}")
1✔
94
            return {}
1✔
95
            
NEW
96
        payload = {'ids': asset_ids}
×
NEW
97
        response = self._make_request('PUT', f"{self.ENDPOINTS['albums']}/{album_id}/assets", json=payload)
×
NEW
98
        return response.json()
×
99
    
100
    # Asset search and retrieval operations
101
    def search_assets_by_metadata(self, original_file_name=None, original_path=None, is_favorite=None, page=None):
1✔
102
        """Search for assets by original filename and path with optional pagination"""
NEW
103
        payload = self._build_search_payload(original_file_name, original_path, is_favorite, page)
×
NEW
104
        response = self._make_request('POST', self.ENDPOINTS['search_metadata'], json=payload)
×
NEW
105
        return response.json()
×
106

107
    def search_assets_updated_since(self, timestamp, page=None):
1✔
108
        """Search for assets updated since a specific timestamp with optional pagination"""
NEW
109
        payload = {'updatedAfter': timestamp}
×
NEW
110
        if page is not None:
×
NEW
111
            payload['page'] = page
×
NEW
112
        response = self._make_request('POST', self.ENDPOINTS['search_metadata'], json=payload)
×
NEW
113
        return response.json()
×
114

115
    def get_all_assets_paginated(self, updated_after=None, is_favorite=None):
1✔
116
        """Get all assets using pagination, yielding pages of results
117
        
118
        Args:
119
            updated_after: ISO timestamp string to filter assets (optional)
120
            is_favorite: Boolean to filter by favorite status (optional)
121
            
122
        Yields:
123
            List of asset dictionaries for each page
124
        """
NEW
125
        page = 1
×
126
        
NEW
127
        while True:
×
NEW
128
            if updated_after:
×
NEW
129
                response = self.search_assets_updated_since(updated_after, page=page)
×
130
            else:
NEW
131
                response = self.search_assets_by_metadata(is_favorite=is_favorite, page=page)
×
132
            
NEW
133
            assets_data = response.get('assets', {})
×
NEW
134
            assets = assets_data.get('items', [])
×
135
            
NEW
136
            if not assets:
×
NEW
137
                break
×
138
                
NEW
139
            yield assets
×
140
            
NEW
141
            next_page = assets_data.get('nextPage')
×
NEW
142
            if next_page is None:
×
NEW
143
                break
×
144
                
NEW
145
            page = int(next_page)
×
146

147
    def get_asset_by_id(self, asset_id):
1✔
148
        """Get detailed asset information by ID"""
NEW
149
        response = self._make_request('GET', f"{self.ENDPOINTS['assets']}/{asset_id}")
×
NEW
150
        return response.json()
×
151
    
152
    # Asset update operations
153
    def update_asset(self, asset_id, is_favorite=None, description=None, file_created_at=None, latitude=None, longitude=None):
1✔
154
        """Update an asset (favorite status, description, date/time, location)"""
155
        if constants.dry_run:
1✔
156
            updates = []
1✔
157
            if is_favorite is not None:
1✔
158
                updates.append(f"favorite: {is_favorite}")
1✔
159
            if description is not None:
1✔
160
                updates.append(f"description: {description}")
1✔
161
            if file_created_at is not None:
1✔
162
                updates.append(f"date: {file_created_at}")
1✔
163
            if latitude is not None and longitude is not None:
1✔
164
                updates.append(f"location: {latitude},{longitude}")
1✔
165
            print(f"[DRY-RUN][Immich] Would update asset {asset_id}: {', '.join(updates)}")
1✔
166
            return True
1✔
167
            
NEW
168
        payload = self._build_update_payload(is_favorite, description, file_created_at, latitude, longitude)
×
NEW
169
        self._make_request('PUT', f"{self.ENDPOINTS['assets']}/{asset_id}", json=payload)
×
NEW
170
        return True
×
171
    
172
    # Helper methods for payload construction
173
    def _build_search_payload(self, original_file_name=None, original_path=None, is_favorite=None, page=None):
1✔
174
        """Build search payload with non-None values"""
NEW
175
        payload = {}
×
NEW
176
        if original_file_name:
×
NEW
177
            payload['originalFileName'] = original_file_name
×
NEW
178
        if original_path:
×
NEW
179
            payload['originalPath'] = original_path
×
NEW
180
        if is_favorite is not None:
×
NEW
181
            payload['isFavorite'] = is_favorite
×
NEW
182
        if page is not None:
×
NEW
183
            payload['page'] = page
×
NEW
184
        return payload
×
185
    
186
    def _build_update_payload(self, is_favorite=None, description=None, file_created_at=None, latitude=None, longitude=None):
1✔
187
        """Build update payload with non-None values"""
NEW
188
        payload = {}
×
NEW
189
        if is_favorite is not None:
×
NEW
190
            payload['isFavorite'] = is_favorite
×
NEW
191
        if description is not None:
×
NEW
192
            payload['description'] = description
×
NEW
193
        if file_created_at is not None:
×
NEW
194
            payload['fileCreatedAt'] = file_created_at
×
NEW
195
        if latitude is not None:
×
NEW
196
            payload['latitude'] = latitude
×
NEW
197
        if longitude is not None:
×
NEW
198
            payload['longitude'] = longitude
×
NEW
199
        return payload
×
200

201

202
class Immich(PluginBase):
1✔
203
    """A class to execute Immich plugin actions.
204
       
205
       Requires a config file with the following configurations set:
206
       api_url:
207
            The API URL of your Immich instance (e.g., https://immich.mydomain.com/api)
208
       api_key:
209
            Your Immich API key for authentication
210
       external_library_path:
211
            The base path for the Immich external library
212
       elodie_library_path:
213
            The base path for elodie to organize photos
214
    """
215

216
    __name__ = 'Immich'
1✔
217
    
218
    # Plugin constants
219
    CLEANUP_RETENTION_DAYS = 14
1✔
220
    GET_ALL_ASSETS_TIMESTAMP = '1800-01-01T00:00:00.000Z'
1✔
221
    PROGRESS_LOG_INTERVAL = 100
1✔
222
    FAVORITE_RATING = 5
1✔
223
    ALBUM_SEPARATOR = ';'
1✔
224

225
    def __init__(self) -> None:
1✔
226
        super(Immich, self).__init__()
1✔
227
        self._initialize_config()
1✔
228
        self.filesystem = FileSystem()
1✔
229
    
230
    def _initialize_config(self) -> None:
1✔
231
        """Initialize plugin configuration from config.ini."""
232
        self.api_url = self.config_for_plugin.get('api_url')
1✔
233
        self.api_key = self.config_for_plugin.get('api_key')
1✔
234
        self.external_library_path = self.config_for_plugin.get('external_library_path')
1✔
235
        self.elodie_library_path = self.config_for_plugin.get('elodie_library_path')
1✔
236
        
237
        # Initialize API client if we have required config
238
        self.client = None
1✔
239
        if self.api_url and self.api_key:
1✔
240
            self.client = ImmichApiClient(self.api_url, self.api_key)
1✔
241

242
    def after(self, file_path: str, destination_folder: str, final_file_path: str, metadata: Dict) -> None:
1✔
243
        """Called after a file is processed.
244
        
245
        File move tracking is handled in batch() where we have asset IDs.
246
        """
NEW
247
        pass
×
248

249
    def batch(self) -> Tuple[bool, int]:
1✔
250
        """Main batch processing method - handles sync operations.
251
        
252
        Returns:
253
            Tuple of (success: bool, processed_count: int)
254
        """
NEW
255
        if not self.client:
×
NEW
256
            self.display('Immich plugin not configured properly. Check api_url and api_key in config.')
×
NEW
257
            return (False, 0)
×
258
            
NEW
259
        if not self.external_library_path:
×
NEW
260
            self.display('Immich plugin missing external_library_path configuration.')
×
NEW
261
            return (False, 0)
×
262
            
NEW
263
        try:
×
264
            # First prune our state to match current Immich assets
NEW
265
            self._prune_immich_states()
×
266
            
267
            # Check if bootstrap has been completed
NEW
268
            bootstrap_completed = self.db.get('bootstrap_completed')
×
269
            
NEW
270
            if not bootstrap_completed:
×
NEW
271
                self.display('Running initial bootstrap sync from Elodie to Immich...')
×
NEW
272
                result = self._bootstrap_elodie_to_immich()
×
NEW
273
                if result[0]:  # If successful
×
NEW
274
                    self.db.set('bootstrap_completed', True)
×
NEW
275
                    self.display('Bootstrap completed successfully')
×
NEW
276
                return result
×
277
            else:
NEW
278
                self.display('Running incremental sync from Immich to Elodie...')
×
NEW
279
                result = self._sync_immich_to_elodie()
×
NEW
280
                return result
×
281
                
NEW
282
        except Exception as e:
×
NEW
283
            self.display(f'Immich sync failed: {str(e)}')
×
NEW
284
            return (False, 0)
×
285

286
    def before(self, file_path: str, destination_folder: str) -> None:
1✔
287
        """Called before a file is processed.
288
        
289
        We don't need to do anything before individual file processing.
290
        """
NEW
291
        pass
×
292

293
    def _prune_immich_states(self) -> None:
1✔
294
        """Prune plugin state to reflect what's in Immich database.
295
        
296
        Removes stale asset IDs from local state that no longer exist in Immich
297
        and cleans up expired file move records.
298
        """
NEW
299
        try:
×
300
            # Get all current assets from Immich using pagination
NEW
301
            all_assets = []
×
NEW
302
            for asset_page in self.client.get_all_assets_paginated(self.GET_ALL_ASSETS_TIMESTAMP):
×
NEW
303
                all_assets.extend(asset_page)
×
NEW
304
            current_asset_ids = {asset['id'] for asset in all_assets}
×
305
            
NEW
306
            self.log(f"Found {len(all_assets)} current assets in Immich")
×
307
            
308
            # Check which tracked asset IDs are stale (no longer exist in Immich)
NEW
309
            immich_states = self.db.get('immich_states') or {}
×
NEW
310
            tracked_asset_ids = set(immich_states.keys())
×
NEW
311
            stale_asset_ids = tracked_asset_ids - current_asset_ids
×
312
            
NEW
313
            if stale_asset_ids:
×
NEW
314
                self.log(f"Found {len(stale_asset_ids)} stale asset IDs: {list(stale_asset_ids)}")
×
315
                
316
                # Remove stale entries from immich_states
NEW
317
                for stale_id in stale_asset_ids:
×
NEW
318
                    if stale_id in immich_states:
×
NEW
319
                        del immich_states[stale_id]
×
NEW
320
                        self.log(f"Removed stale asset ID {stale_id} from immich_states")
×
321
                
322
                # Save cleaned immich_states back to database
NEW
323
                self.db.set('immich_states', immich_states)
×
324
                
325
                # Clean up expired file move records
NEW
326
                self._cleanup_expired_file_moves()
×
NEW
327
                self.log("State reconciliation completed - removed stale entries")
×
328
            else:
NEW
329
                self.log("No stale asset IDs found - state is current")
×
330
                
NEW
331
        except Exception as e:
×
NEW
332
            self.log(f"Error during state reconciliation: {e}")
×
333

334
    def _bootstrap_elodie_to_immich(self) -> Tuple[bool, int]:
1✔
335
        """Bootstrap sync: Elodie → Immich (one-time setup).
336
        
337
        Syncs all existing files from Elodie to Immich with resume capability.
338
        
339
        Returns:
340
            Tuple of (success: bool, processed_count: int)
341
        """
NEW
342
        count = 0
×
NEW
343
        errors = 0
×
344
        
NEW
345
        try:
×
346
            # Get or initialize processed files list for resume capability
NEW
347
            processed_files = set(self.db.get('bootstrap_processed_files') or [])
×
NEW
348
            total_processed = len(processed_files)
×
349
            
NEW
350
            if total_processed > 0:
×
NEW
351
                self.log(f'Resuming bootstrap - {total_processed} files already processed')
×
352
            
353
            # Get all albums that exist in Immich for mapping
NEW
354
            immich_albums = self.client.get_all_albums()
×
NEW
355
            album_name_to_id = {album['albumName']: album['id'] for album in immich_albums}
×
356
            
357
            # Iterate through all files in elodie's library path
NEW
358
            all_files = list(self.filesystem.get_all_files(self.elodie_library_path))
×
NEW
359
            total_files_count = len(all_files)
×
NEW
360
            self.log(f'Bootstrap processing {total_files_count} total files ({total_processed} already completed)')
×
361
            
NEW
362
            for file_path in all_files:
×
363
                # Skip files already processed
NEW
364
                if file_path in processed_files:
×
NEW
365
                    continue
×
NEW
366
                try:
×
367
                    # Process single file for bootstrap
NEW
368
                    asset_id = self._process_file_for_bootstrap(file_path)
×
NEW
369
                    if not asset_id:
×
NEW
370
                        continue
×
371
                    
NEW
372
                    if self._sync_single_file_to_immich(file_path, asset_id, album_name_to_id):
×
NEW
373
                        count += 1
×
NEW
374
                        self._track_bootstrap_progress(processed_files, file_path, total_files_count)
×
375
                    
NEW
376
                except Exception as e:
×
NEW
377
                    self.log(f'Error processing {file_path}: {str(e)}')
×
NEW
378
                    self.log(f'Exception type: {type(e).__name__}')
×
NEW
379
                    errors += 1
×
NEW
380
                    continue
×
381
                    
NEW
382
        except Exception as e:
×
NEW
383
            self.display(f'Bootstrap sync failed: {str(e)}')
×
NEW
384
            return (False, count)
×
385
        
386
        # Log completion and clean up progress tracking
NEW
387
        total_processed_count = len(processed_files)
×
NEW
388
        final_progress_pct = (total_processed_count / total_files_count) * 100 if total_files_count > 0 else 0
×
NEW
389
        self.display(f'Bootstrap completed: {count} files processed successfully, {errors} errors')
×
NEW
390
        self.log(f'Final progress: {total_processed_count}/{total_files_count} files ({final_progress_pct:.1f}%)')
×
391
        
392
        # Clean up bootstrap progress tracking since we're done
NEW
393
        self.db.set('bootstrap_processed_files', None)
×
394
        
NEW
395
        return (True, count)
×
396

397
    def _sync_immich_to_elodie(self) -> Tuple[bool, int]:
1✔
398
        """Incremental sync: Immich → Elodie.
399
        
400
        Syncs changes from Immich back to Elodie metadata.
401
        
402
        Returns:
403
            Tuple of (success: bool, processed_count: int)
404
        """
NEW
405
        count = 0
×
NEW
406
        errors = 0
×
NEW
407
        self.safe_to_update_assets = set()  # Track assets safe to update state for
×
408
        
NEW
409
        try:
×
410
            # Get assets updated since last sync
NEW
411
            last_sync_timestamp = self.db.get('last_sync_timestamp')
×
NEW
412
            updated_assets = []
×
NEW
413
            if last_sync_timestamp:
×
NEW
414
                for asset_page in self.client.get_all_assets_paginated(last_sync_timestamp):
×
NEW
415
                    updated_assets.extend(asset_page)
×
416
            else:
417
                # First run - get all assets
NEW
418
                for asset_page in self.client.get_all_assets_paginated(self.GET_ALL_ASSETS_TIMESTAMP):
×
NEW
419
                    updated_assets.extend(asset_page)
×
420
            
NEW
421
            self.log(f'Found {len(updated_assets)} assets updated since last sync')
×
422
            
423
            # Get detailed info for each updated asset
NEW
424
            detailed_assets = {}
×
NEW
425
            for asset in updated_assets:
×
NEW
426
                asset_id = asset['id']
×
NEW
427
                try:
×
NEW
428
                    detailed_asset = self.client.get_asset_by_id(asset_id)
×
NEW
429
                    detailed_assets[asset_id] = detailed_asset
×
NEW
430
                except Exception as e:
×
NEW
431
                    self.log(f'Failed to get details for asset {asset_id}: {e}')
×
432
            
433
            # Get current album membership from Immich (for state comparison)
NEW
434
            all_albums = self.client.get_all_albums()
×
NEW
435
            current_membership = {}  # asset_id -> [album_names]
×
436
            
437
            # Fetch each album individually to get its assets
NEW
438
            for album_summary in all_albums:
×
NEW
439
                album_id = album_summary.get('id')
×
NEW
440
                album_name = album_summary.get('albumName')
×
441
                
442
                # Get full album data with assets
NEW
443
                album_detail = self.client.get_album_by_id(album_id)
×
NEW
444
                album_assets = album_detail.get('assets', [])
×
445
                
NEW
446
                self.log(f'Album "{album_name}" has {len(album_assets)} assets')
×
447
                
NEW
448
                for album_asset in album_assets:
×
NEW
449
                    asset_id = album_asset.get('id')
×
NEW
450
                    if asset_id:
×
NEW
451
                        if asset_id not in current_membership:
×
NEW
452
                            current_membership[asset_id] = []
×
NEW
453
                        current_membership[asset_id].append(album_name)
×
454
            
455
            # Get current favorite state from Immich using search
NEW
456
            current_favorites = {}  # asset_id -> is_favorite
×
457
            
458
            # Get favorited assets - all others are implicitly not favorited
NEW
459
            favorite_assets = []
×
NEW
460
            for asset_page in self.client.get_all_assets_paginated(is_favorite=True):
×
NEW
461
                favorite_assets.extend(asset_page)
×
NEW
462
            self.log(f'Found {len(favorite_assets)} favorite assets')
×
NEW
463
            for asset in favorite_assets:
×
NEW
464
                asset_id = asset.get('id')
×
NEW
465
                if asset_id:
×
NEW
466
                    current_favorites[asset_id] = True
×
467
            
468
            # Store current Immich states for reverse lookup (we'll populate this as we process assets)
NEW
469
            immich_states = self.db.get('immich_states') or {}
×
470
            
471
            # Get previous state from plugin database
NEW
472
            previous_membership = self.db.get('album_membership') or {}
×
NEW
473
            previous_favorites = self.db.get('favorite_state') or {}
×
474
            
NEW
475
            self.log(f'Current membership has {len(current_membership)} assets, previous had {len(previous_membership)}')
×
476
            
477
            # Find assets with changed album membership or favorite status
NEW
478
            all_asset_ids = set(current_membership.keys()) | set(previous_membership.keys()) | set(current_favorites.keys()) | set(previous_favorites.keys())
×
NEW
479
            changed_assets = []
×
480
            
NEW
481
            for asset_id in all_asset_ids:
×
NEW
482
                current_albums = set(current_membership.get(asset_id, []))
×
NEW
483
                previous_albums = set(previous_membership.get(asset_id, []))
×
NEW
484
                current_favorite = current_favorites.get(asset_id, False)
×
NEW
485
                previous_favorite = previous_favorites.get(asset_id, False)
×
486
                
NEW
487
                album_changed = current_albums != previous_albums
×
NEW
488
                favorite_changed = current_favorite != previous_favorite
×
489
                
NEW
490
                if album_changed or favorite_changed:
×
NEW
491
                    changed_assets.append(asset_id)
×
NEW
492
                    if album_changed:
×
NEW
493
                        self.log(f'Album change detected for asset {asset_id}: {sorted(previous_albums)} -> {sorted(current_albums)}')
×
NEW
494
                    if favorite_changed:
×
NEW
495
                        self.log(f'Favorite change detected for asset {asset_id}: {previous_favorite} -> {current_favorite}')
×
496
            
497
            # Build asset info lookup for changed assets
NEW
498
            asset_info_lookup = {}
×
499
            
500
            # Get asset info from albums
NEW
501
            for album_summary in all_albums:
×
NEW
502
                album_id = album_summary.get('id')
×
NEW
503
                album_detail = self.client.get_album_by_id(album_id)
×
NEW
504
                for album_asset in album_detail.get('assets', []):
×
NEW
505
                    asset_id = album_asset.get('id')
×
NEW
506
                    if asset_id:
×
NEW
507
                        asset_info_lookup[asset_id] = album_asset
×
508
            
509
            # Also get asset info from favorite assets (for assets not in any album)
NEW
510
            for asset in favorite_assets:
×
NEW
511
                asset_id = asset.get('id')
×
NEW
512
                if asset_id and asset_id not in asset_info_lookup:
×
NEW
513
                    asset_info_lookup[asset_id] = asset
×
514
            
NEW
515
            self.log(f'Asset info lookup has {len(asset_info_lookup)} assets')
×
516
            
517
            # Debug: Show current asset IDs and paths in Immich
NEW
518
            current_assets_debug = []
×
NEW
519
            for album_summary in all_albums:
×
NEW
520
                album_detail = self.client.get_album_by_id(album_summary.get('id'))
×
NEW
521
                for asset in album_detail.get('assets', []):
×
NEW
522
                    current_assets_debug.append({
×
523
                        'id': asset.get('id'),
524
                        'path': asset.get('originalPath'),
525
                        'filename': asset.get('originalFileName')
526
                    })
NEW
527
            self.log(f'Current assets in Immich: {current_assets_debug}')
×
528
            
529
            # Bootstrap moved files that haven't been processed yet
NEW
530
            self._bootstrap_moved_files()
×
531
            
532
            # Add assets with album/favorite changes to the processing list
NEW
533
            for asset_id in changed_assets:
×
NEW
534
                if asset_id not in detailed_assets and asset_id in asset_info_lookup:
×
535
                    # Get detailed asset info for this changed asset
NEW
536
                    try:
×
NEW
537
                        detailed_asset = self.client.get_asset_by_id(asset_id)
×
NEW
538
                        detailed_assets[asset_id] = detailed_asset
×
NEW
539
                    except Exception as e:
×
NEW
540
                        self.log(f'Could not get detailed info for changed asset {asset_id}: {e}')
×
541
            
542
            # Process all assets (both updated from search and changed from membership)
NEW
543
            for asset_id, detailed_asset in detailed_assets.items():
×
NEW
544
                try:
×
545
                    # Use detailed asset info
NEW
546
                    asset_info = detailed_asset
×
NEW
547
                    exif_info = detailed_asset.get('exifInfo', {})
×
548
                    
549
                    # Store/update asset info in immich_states for reverse lookup
NEW
550
                    immich_states[asset_id] = {
×
551
                        'originalPath': asset_info.get('originalPath'),
552
                        'originalFileName': asset_info.get('originalFileName'),
553
                        'albums': current_membership.get(asset_id, []),
554
                        'isFavorite': asset_info.get('isFavorite', False),
555
                        'description': exif_info.get('description'),
556
                        'latitude': exif_info.get('latitude'),
557
                        'longitude': exif_info.get('longitude')
558
                    }
559
                    
NEW
560
                    if not asset_info:
×
NEW
561
                        self.log(f'Could not find asset info for {asset_id}')
×
NEW
562
                        continue
×
563
                    
NEW
564
                    self.log(f'Processing asset: {asset_id} - {asset_info.get("originalFileName", "unknown")}')
×
565
                    
566
                    # Find the corresponding file in Elodie
NEW
567
                    file_path = self._find_file_for_asset(asset_info)
×
NEW
568
                    if not file_path:
×
NEW
569
                        self.log(f'Could not find file for asset {asset_id}')
×
NEW
570
                        self.log(f'Asset originalPath: {asset_info.get("originalPath")}')
×
NEW
571
                        self.log(f'Asset originalFileName: {asset_info.get("originalFileName")}')
×
NEW
572
                        continue
×
573
                        
574
                    # Get media object
NEW
575
                    media = Base.get_class_by_file(file_path, get_all_subclasses())
×
NEW
576
                    if not media:
×
NEW
577
                        continue
×
578
                        
NEW
579
                    updated = False
×
580
                    
581
                    # Apply album changes
NEW
582
                    current_albums = set(current_membership.get(asset_id, []))
×
NEW
583
                    previous_albums = set(previous_membership.get(asset_id, []))
×
584
                    
NEW
585
                    album_changed = False
×
NEW
586
                    if current_albums != previous_albums:
×
NEW
587
                        if current_albums:
×
588
                            # Join multiple albums with semicolon separator
NEW
589
                            album_string = ';'.join(sorted(current_albums))
×
NEW
590
                            media.set_album(album_string)
×
NEW
591
                            self.log(f'Updated albums for {file_path} to: {sorted(current_albums)}')
×
NEW
592
                        updated = True
×
NEW
593
                        album_changed = True
×
594
                    
595
                    # Apply favorite changes  
596
                    # TODO: Consider using exifInfo.rating instead of isFavorite to streamline API calls
NEW
597
                    current_favorite = current_favorites.get(asset_id, False)
×
NEW
598
                    previous_favorite = previous_favorites.get(asset_id, False)
×
599
                    
NEW
600
                    if current_favorite != previous_favorite:
×
NEW
601
                        if current_favorite:
×
NEW
602
                            media.set_rating(5)
×
603
                        else:
NEW
604
                            media.set_rating('')
×
NEW
605
                        self.log(f'Updated favorite for {file_path} to: {current_favorite}')
×
NEW
606
                        updated = True
×
607
                    
608
                    # Apply description changes
609
                    # TODO: Temporarily disabled - was causing sync loops with 20k photo library
610
                    # current_description = exif_info.get('description')
611
                    # previous_immich_states = self.db.get('immich_states') or {}
612
                    # previous_description = previous_immich_states.get(asset_id, {}).get('description')
613
                    # 
614
                    # # TODO: Added truthy conditions
615
                    # # if current_description != previous_description:
616
                    # if current_description && previous_description && current_description != previous_description:
617
                    #     media.set_description(current_description or '')
618
                    #     self.log(f'Updated description for {file_path} to: {current_description}')
619
                    #     updated = True
620
                    
621
                    # Note: Date/time synchronization is disabled to avoid timezone issues
622
                    # that cause endless file rename cycles
623
                    
624
                    # Apply location changes
NEW
625
                    current_lat = exif_info.get('latitude')
×
NEW
626
                    current_lng = exif_info.get('longitude') 
×
NEW
627
                    previous_lat = previous_immich_states.get(asset_id, {}).get('latitude')
×
NEW
628
                    previous_lng = previous_immich_states.get(asset_id, {}).get('longitude')
×
629
                    
630
                    # TODO: make location change less precise
631
                    # Lookup precision difference with immich
NEW
632
                    location_changed = False
×
633
                    # if (current_lat != previous_lat or current_lng != previous_lng) and current_lat is not None and current_lng is not None:
634
                    #     media.set_location(current_lat, current_lng)
635
                    #     self.log(f'Updated location for {file_path} to: {current_lat}, {current_lng}')
636
                    #     updated = True
637
                    #     location_changed = True
638
                        
639
                    # Only reprocess file for changes that affect file path (album or location changes)
640
                    # Description and favorite changes don't require file moves
NEW
641
                    if album_changed or location_changed:
×
NEW
642
                        self.log(f'File needs reprocessing due to album/location changes: {file_path}')
×
NEW
643
                        self.log(f'  Album changed: {album_changed}, Location changed: {location_changed}')
×
NEW
644
                        if constants.dry_run:
×
NEW
645
                            self.display(f'[DRY-RUN] Would move file {file_path} due to album/location changes')
×
646
                        else:
NEW
647
                            self.log(f'Creating media object for file: {file_path}')
×
NEW
648
                            updated_media = Base.get_class_by_file(file_path, get_all_subclasses())
×
NEW
649
                            if not updated_media:
×
NEW
650
                                self.log(f'Failed to create media object for: {file_path}')
×
NEW
651
                                continue
×
NEW
652
                            self.log(f'Processing file with filesystem.process_file: {file_path}')
×
NEW
653
                            new_path = self.filesystem.process_file(
×
654
                                file_path, 
655
                                self.elodie_library_path, 
656
                                updated_media,
657
                                move=True
658
                            )
NEW
659
                            if new_path and new_path != file_path:
×
660
                                # File was moved - record the asset ID translation
NEW
661
                                file_moves = self.db.get('file_moves') or {}
×
NEW
662
                                file_moves[asset_id] = {
×
663
                                    'old_path': file_path,
664
                                    'new_path': new_path,
665
                                    'new_asset_id': None,  # Will be populated when Immich processes the move
666
                                    'timestamp': datetime.utcnow().isoformat() + 'Z'
667
                                }
NEW
668
                                self.db.set('file_moves', file_moves)
×
NEW
669
                                self.display(f'Recorded file move: asset {asset_id} {file_path} -> {new_path}')
×
670
                                
671
                                # Clean up empty directories after moving files
NEW
672
                                old_directory = os.path.dirname(file_path)
×
NEW
673
                                self.filesystem.delete_directory_if_empty(old_directory)
×
674
                                # Also try parent directory in case it's also empty
NEW
675
                                self.filesystem.delete_directory_if_empty(os.path.dirname(old_directory))
×
676
                            else:
677
                                # No file move needed - changes were applied successfully
NEW
678
                                self.log(f'Changes applied successfully to {file_path}')
×
679
                            
680
                            # Update our stored state immediately since changes were successful
NEW
681
                            if asset_id in immich_states:
×
682
                                # Update album membership
NEW
683
                                current_membership[asset_id] = immich_states[asset_id]['albums']
×
684
                                
685
                                # Update favorites
NEW
686
                                is_fav = immich_states[asset_id]['isFavorite']
×
NEW
687
                                if is_fav:
×
NEW
688
                                    current_favorites[asset_id] = True
×
689
                                else:
NEW
690
                                    current_favorites.pop(asset_id, None)
×
691
                            
NEW
692
                        count += 1
×
693
                        
NEW
694
                except Exception as e:
×
NEW
695
                    self.log(f'Error processing asset {asset_id}: {str(e)}')
×
NEW
696
                    errors += 1
×
NEW
697
                    continue
×
698
            
699
            # Save updated immich states
NEW
700
            self.db.set('immich_states', immich_states)
×
701
            
702
            # Simple state management: store current state after successful changes
703
            # (current_membership and current_favorites have been updated above for successful changes)
NEW
704
            self.db.set('album_membership', current_membership)
×
NEW
705
            self.db.set('favorite_state', current_favorites)
×
706
                    
707
            # Update last sync timestamp
NEW
708
            self.db.set('last_sync_timestamp', datetime.utcnow().isoformat() + 'Z')
×
709
            
NEW
710
        except Exception as e:
×
NEW
711
            self.display(f'Incremental sync failed: {str(e)}')
×
NEW
712
            return (False, count)
×
713
            
NEW
714
        self.display(f'Incremental sync completed: {count} files updated, {errors} errors')
×
NEW
715
        return (True, count)
×
716

717
    # File path resolution helpers
718
    def _find_asset_id_for_path(self, file_path: str) -> Optional[str]:
1✔
719
        """Find the asset ID for a given file path from stored Immich state."""
NEW
720
        immich_states = self.db.get('immich_states') or {}
×
NEW
721
        for asset_id, asset_info in immich_states.items():
×
NEW
722
            if asset_info.get('originalPath') == file_path:
×
NEW
723
                return asset_id
×
NEW
724
        return None
×
725
    
726
    def _translate_immich_path_to_elodie(self, immich_path: str) -> Optional[str]:
1✔
727
        """Translate an Immich file path to the corresponding Elodie path.
728
        
729
        Requires external_library_path and elodie_library_path to be configured with
730
        matching directory structures. Both paths should NOT have trailing slashes.
731
        
732
        Example config:
733
            external_library_path=/mnt/server1/photos
734
            elodie_library_path=/home/user/photos
735
        """
NEW
736
        if not immich_path or not self.external_library_path or not self.elodie_library_path:
×
NEW
737
            return None
×
738
            
NEW
739
        if immich_path.startswith(self.external_library_path):
×
NEW
740
            relative_path = immich_path[len(self.external_library_path):]
×
NEW
741
            return self.elodie_library_path + relative_path
×
742
            
NEW
743
        return None
×
744

745
    def _find_file_for_asset(self, asset: Dict) -> Optional[str]:
1✔
746
        """Find the local file path for an Immich asset using translation layer."""
NEW
747
        asset_id = asset['id']
×
NEW
748
        original_path = asset.get('originalPath')
×
749
        
750
        # First check if this asset was moved and we have a translation
NEW
751
        moved_path = self._get_moved_file_path(asset_id)
×
NEW
752
        if moved_path:
×
NEW
753
            return moved_path
×
754
                
755
        # If no move recorded, try the original path from Immich
NEW
756
        if original_path and isfile(original_path):
×
NEW
757
            return original_path
×
758
        
759
        # Try translating the Immich path to the Elodie path
NEW
760
        if original_path:
×
NEW
761
            translated_path = self._translate_immich_path_to_elodie(original_path)
×
NEW
762
            if translated_path and isfile(translated_path):
×
NEW
763
                return translated_path
×
764
                    
NEW
765
        return None
×
766
    
767
    def _get_moved_file_path(self, asset_id: str) -> Optional[str]:
1✔
768
        """Get the moved file path for an asset if it exists and is valid."""
NEW
769
        file_moves = self.db.get('file_moves') or {}
×
NEW
770
        if asset_id in file_moves:
×
NEW
771
            move_info = file_moves[asset_id]
×
NEW
772
            new_path = move_info.get('new_path')
×
NEW
773
            if new_path and isfile(new_path):
×
NEW
774
                return new_path
×
NEW
775
        return None
×
776
    
777
    def _find_asset_by_file_info(self, file_path: str) -> Optional[Dict]:
1✔
778
        """Find Immich asset by searching with filename and path."""
NEW
779
        original_filename = basename(file_path)
×
NEW
780
        search_results = self.client.search_assets_by_metadata(
×
781
            original_file_name=original_filename,
782
            original_path=file_path
783
        )
784
        
NEW
785
        assets_data = search_results.get('assets', {})
×
NEW
786
        assets = assets_data.get('items', [])
×
NEW
787
        return assets[0] if assets else None
×
788
    
789
    # Bootstrap helper methods
790
    def _track_bootstrap_progress(self, processed_files_set: Set[str], file_path: str, total_files_count: int) -> None:
1✔
791
        """Track and log bootstrap progress for the given file.
792
        
793
        Args:
794
            processed_files_set: Set of file paths that have been successfully processed
795
            file_path: Current file path being processed  
796
            total_files_count: Total number of files to process
797
        """
NEW
798
        processed_files_set.add(file_path)
×
NEW
799
        self.db.set('bootstrap_processed_files', list(processed_files_set))
×
800
        
801
        # Log progress every interval
NEW
802
        if len(processed_files_set) % self.PROGRESS_LOG_INTERVAL == 0:
×
NEW
803
            progress_pct = (len(processed_files_set) / total_files_count) * 100
×
NEW
804
            self.log(f'Bootstrap progress: {len(processed_files_set)}/{total_files_count} files ({progress_pct:.1f}%)')
×
805

806
    def _process_file_for_bootstrap(self, file_path: str) -> Optional[str]:
1✔
807
        """Process a single file during bootstrap and return its asset ID."""
808
        # Get media object and metadata
NEW
809
        media = Base.get_class_by_file(file_path, get_all_subclasses())
×
NEW
810
        if not media:
×
NEW
811
            return None
×
812
            
NEW
813
        metadata = media.get_metadata()
×
NEW
814
        if not metadata:
×
NEW
815
            return None
×
816
        
817
        # Find corresponding Immich asset
NEW
818
        asset = self._find_asset_by_file_info(file_path)
×
NEW
819
        if not asset:
×
NEW
820
            original_filename = basename(file_path)
×
NEW
821
            self.log(f'No Immich asset found for {file_path} (filename: {original_filename})')
×
NEW
822
            return None
×
823
            
NEW
824
        return asset['id']
×
825
    
826

827
    def _bootstrap_moved_files(self) -> None:
1✔
828
        """Bootstrap album/favorite state for files that were moved but not yet processed by Immich.
829
        
830
        Handles files that have been moved by Elodie but haven't been reprocessed by Immich yet.
831
        """
NEW
832
        file_moves = self.db.get('file_moves') or {}
×
NEW
833
        updated_moves = {}
×
834
        
NEW
835
        for old_asset_id, move_info in file_moves.items():
×
836
            # Skip if already bootstrapped (has new_asset_id)
NEW
837
            if move_info.get('new_asset_id'):
×
NEW
838
                updated_moves[old_asset_id] = move_info
×
NEW
839
                continue
×
840
                
NEW
841
            new_path = move_info['new_path']
×
NEW
842
            self.log(f'Attempting to bootstrap moved file: {new_path}')
×
843
            
NEW
844
            try:
×
845
                # Search for asset at the new path
NEW
846
                search_results = self.client.search_assets_by_metadata(
×
847
                    original_file_name=basename(new_path),
848
                    original_path=new_path
849
                )
850
                
NEW
851
                assets = search_results.get('assets', {}).get('items', [])
×
NEW
852
                if not assets:
×
NEW
853
                    self.log(f'No asset found for moved file {new_path}')
×
NEW
854
                    self.log(f'  - Searched by filename: {basename(new_path)}')
×
NEW
855
                    self.log(f'  - Searched by path: {new_path}')
×
NEW
856
                    self.log(f'  - Old asset ID was: {old_asset_id}')
×
NEW
857
                    updated_moves[old_asset_id] = move_info
×
NEW
858
                    continue
×
859
                    
NEW
860
                asset = assets[0]
×
NEW
861
                new_asset_id = asset['id']
×
NEW
862
                self.log(f'Found new asset ID {new_asset_id} for moved file {new_path}')
×
863
                
864
                # Read EXIF metadata from the file
NEW
865
                media = Base.get_class_by_file(new_path, get_all_subclasses())
×
NEW
866
                if not media:
×
NEW
867
                    self.log(f'Could not create media object for {new_path}')
×
NEW
868
                    updated_moves[old_asset_id] = move_info
×
NEW
869
                    continue
×
870
                    
NEW
871
                metadata = media.get_metadata()
×
NEW
872
                if not metadata:
×
NEW
873
                    self.log(f'Could not get metadata for {new_path}')
×
NEW
874
                    updated_moves[old_asset_id] = move_info
×
NEW
875
                    continue
×
876
                
877
                # Sync Elodie metadata to Immich for this moved file
NEW
878
                all_albums = self.client.get_all_albums()
×
NEW
879
                album_name_to_id = {album['albumName']: album['id'] for album in all_albums}
×
NEW
880
                self._sync_single_file_to_immich(new_path, new_asset_id, album_name_to_id)
×
881
                
882
                # Update the file move record with new asset ID
NEW
883
                move_info['new_asset_id'] = new_asset_id
×
NEW
884
                updated_moves[old_asset_id] = move_info
×
NEW
885
                self.log(f'Successfully bootstrapped moved file: {old_asset_id} -> {new_asset_id}')
×
886
                
NEW
887
            except Exception as e:
×
NEW
888
                self.log(f'Error bootstrapping moved file {new_path}: {str(e)}')
×
NEW
889
                updated_moves[old_asset_id] = move_info
×
890
                
891
        # Save updated file moves
NEW
892
        self.db.set('file_moves', updated_moves)
×
893

894
    def _restore_album_membership_after_move(self, old_asset_id, new_asset_id, file_path, metadata):
1✔
895
        """Restore album memberships after a file move to match EXIF data"""
NEW
896
        try:
×
897
            # Get album names from EXIF metadata
NEW
898
            album_string = metadata.get('album')
×
NEW
899
            if not album_string:
×
NEW
900
                self.log(f'No album data in EXIF for {file_path}')
×
NEW
901
                return
×
902
                
NEW
903
            exif_albums = set(name.strip() for name in album_string.split(';') if name.strip())
×
NEW
904
            self.log(f'Restoring albums from EXIF for {file_path}: {sorted(exif_albums)}')
×
905
            
906
            # Get all albums and create mapping
NEW
907
            all_albums = self.client.get_all_albums()
×
NEW
908
            album_name_to_id = {album['albumName']: album['id'] for album in all_albums}
×
909
            
910
            # Restore all EXIF albums to Immich
NEW
911
            for album_name in exif_albums:
×
NEW
912
                try:
×
913
                    # Create album if it doesn't exist
NEW
914
                    if album_name not in album_name_to_id:
×
NEW
915
                        self.log(f'Creating missing album: {album_name}')
×
NEW
916
                        new_album = self.client.create_album(album_name)
×
NEW
917
                        album_name_to_id[album_name] = new_album['id']
×
918
                        
919
                    # Add asset to album
NEW
920
                    album_id = album_name_to_id[album_name]
×
NEW
921
                    self.log(f'Adding asset {new_asset_id} to album {album_name}')
×
NEW
922
                    self.client.add_assets_to_album(album_id, [new_asset_id])
×
923
                    
NEW
924
                except Exception as e:
×
NEW
925
                    self.log(f'Error restoring album {album_name} for asset {new_asset_id}: {e}')
×
926
            
927
            # Update tracking with the restored albums
NEW
928
            album_membership = self.db.get('album_membership') or {}
×
NEW
929
            album_membership[new_asset_id] = list(exif_albums)
×
930
            
931
            # Remove old asset ID from tracking
NEW
932
            if old_asset_id in album_membership:
×
NEW
933
                del album_membership[old_asset_id]
×
934
                
NEW
935
            self.db.set('album_membership', album_membership)
×
NEW
936
            self.log(f'Updated album membership tracking for asset {new_asset_id}: {sorted(exif_albums)}')
×
937
            
NEW
938
        except Exception as e:
×
NEW
939
            self.log(f'Error in _restore_album_membership_after_move: {e}')
×
940

941
    def _sync_single_file_to_immich(self, file_path: str, asset_id: str, album_name_to_id: Dict[str, str]) -> bool:
1✔
942
        """Sync a single file's metadata from Elodie to Immich.
943
        
944
        Args:
945
            file_path: Path to the file to sync
946
            asset_id: Immich asset ID
947
            album_name_to_id: Mapping of album names to Immich album IDs
948
            
949
        Returns:
950
            True if sync was successful, False otherwise
951
        """
NEW
952
        try:
×
953
            # Get media object and metadata
NEW
954
            media = Base.get_class_by_file(file_path, get_all_subclasses())
×
NEW
955
            if not media:
×
NEW
956
                return False
×
957
                
NEW
958
            metadata = media.get_metadata()
×
NEW
959
            if not metadata:
×
NEW
960
                return False
×
961
            
962
            # Handle album sync
NEW
963
            album_string = metadata.get('album')
×
NEW
964
            if album_string:
×
NEW
965
                albums = [name.strip() for name in album_string.split(';') if name.strip()]
×
966
                
NEW
967
                for album in albums:
×
968
                    # Ensure album exists in Immich
NEW
969
                    if album not in album_name_to_id:
×
NEW
970
                        new_album = self.client.create_album(album)
×
NEW
971
                        album_name_to_id[album] = new_album['id']
×
972
                        
973
                    # Add asset to album
NEW
974
                    self.client.add_assets_to_album(album_name_to_id[album], [asset_id])
×
975
                
976
            # Handle favorite sync
NEW
977
            rating = metadata.get('rating')
×
NEW
978
            is_favorite = rating == 5 if rating else False
×
NEW
979
            self.client.update_asset(asset_id, is_favorite=is_favorite)
×
980
            
NEW
981
            return True
×
982
            
NEW
983
        except Exception as e:
×
NEW
984
            self.log(f'Error syncing {file_path}: {str(e)}')
×
NEW
985
            return False
×
986

987
    def _cleanup_expired_file_moves(self) -> None:
1✔
988
        """Remove file move records older than the configured retention period.
989
        
990
        Cleans up file move tracking records that are older than CLEANUP_RETENTION_DAYS.
991
        """
NEW
992
        try:
×
NEW
993
            file_moves = self.db.get('file_moves') or {}
×
NEW
994
            moves_to_remove = []
×
NEW
995
            cutoff_time = datetime.utcnow() - timedelta(days=14)
×
996
            
NEW
997
            for move_key, move_data in file_moves.items():
×
NEW
998
                move_timestamp_str = move_data.get('timestamp')
×
NEW
999
                if move_timestamp_str:
×
NEW
1000
                    try:
×
1001
                        # Parse ISO format timestamp
NEW
1002
                        move_timestamp = datetime.fromisoformat(move_timestamp_str.replace('Z', '+00:00'))
×
NEW
1003
                        if move_timestamp.replace(tzinfo=None) < cutoff_time:
×
NEW
1004
                            moves_to_remove.append(move_key)
×
NEW
1005
                    except (ValueError, AttributeError) as e:
×
NEW
1006
                        self.log(f"Invalid timestamp in move record {move_key}: {move_timestamp_str}")
×
1007
            
NEW
1008
            if moves_to_remove:
×
NEW
1009
                for move_key in moves_to_remove:
×
NEW
1010
                    del file_moves[move_key]
×
NEW
1011
                    self.log(f"Removed expired file move entry {move_key} (older than 14 days)")
×
1012
                
NEW
1013
                self.db.set('file_moves', file_moves)
×
NEW
1014
                self.log(f"Cleaned up {len(moves_to_remove)} expired file move records")
×
1015
            
NEW
1016
        except Exception as e:
×
NEW
1017
            self.log(f'Error cleaning up expired file moves: {e}')
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc