Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c6d0e74
Initial working immich plugin
jmathai Dec 28, 2025
83ad74c
Working plugin
jmathai Dec 31, 2025
b46e040
Refactored plugin
jmathai Dec 31, 2025
5c4d022
Update Readme
jmathai Dec 31, 2025
0e1e89e
Update readme
jmathai Dec 31, 2025
45c5764
Readme updates
jmathai Dec 31, 2025
7751557
Add dry run support and tests
jmathai Jan 2, 2026
b2f26b9
Add more tests
jmathai Jan 2, 2026
b7e367f
Add pagination when getting assets
jmathai Jan 3, 2026
c9587fe
Start with page 1
jmathai Jan 3, 2026
7aba46a
Remove asset list
jmathai Jan 3, 2026
664dda2
Fix calls to paginated functions
jmathai Jan 3, 2026
fa7e570
Add pagination debug
jmathai Jan 3, 2026
607c4fe
Support running elodie and immich on different computers
jmathai Jan 3, 2026
4bdc044
add debugging
jmathai Jan 3, 2026
f7af423
more logs
jmathai Jan 3, 2026
558a027
Fix path issue
jmathai Jan 3, 2026
646284e
Remove debugging
jmathai Jan 3, 2026
c029e70
comment out description and lat/lon changes temporarily
jmathai Jan 13, 2026
5915c45
Change path for elodie photos
jmathai Jan 14, 2026
8422628
change resolve elodie path to immich asset id
jmathai Jan 14, 2026
9ee9c65
fix error
jmathai Jan 14, 2026
bd0e006
fix error
jmathai Jan 14, 2026
e0cf435
commented out some debug logs
jmathai Jan 15, 2026
b0ec44c
convert file path
jmathai Jan 15, 2026
8ce2804
paths
jmathai Jan 15, 2026
8517ee0
Add timeout of 10 seconds to immich api calls
jmathai Feb 6, 2026
6292762
Add logging to find where it is hanging
jmathai Feb 7, 2026
6c284c5
More logging to detect hanging
jmathai Feb 7, 2026
3a0a184
add batching and normalize description/location values and enable
jmathai Feb 15, 2026
66ae687
remove batch
jmathai Feb 15, 2026
b7489ea
fix variable reference error
jmathai Feb 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions elodie/media/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ def get_album(self):
"""
return None

def get_rating(self):
"""Base method for getting a rating

:returns: None
"""
return None

def get_file_path(self):
"""Get the full path to the video.

Expand Down Expand Up @@ -96,7 +103,9 @@ def get_metadata(self, update_cache=False):
'latitude': self.get_coordinate('latitude'),
'longitude': self.get_coordinate('longitude'),
'album': self.get_album(),
'rating': self.get_rating(),
'title': self.get_title(),
'description': self.get_description(),
'mime_type': self.get_mimetype(),
'original_name': self.get_original_name(),
'base_name': os.path.splitext(os.path.basename(source))[0],
Expand Down Expand Up @@ -137,6 +146,13 @@ def get_title(self):
"""
return None

def get_description(self):
"""Base method for getting the description of a file

:returns: None
"""
return None

def is_valid(self):
"""Check the file extension against valid file extensions.

Expand Down
72 changes: 72 additions & 0 deletions elodie/media/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ def __init__(self, source=None):
self.camera_model_keys = ['EXIF:Model', 'QuickTime:Model']
self.album_keys = ['XMP-xmpDM:Album', 'XMP:Album']
self.title_key = 'XMP:Title'
self.description_key = 'XMP:Description'
self.latitude_keys = ['EXIF:GPSLatitude']
self.longitude_keys = ['EXIF:GPSLongitude']
self.latitude_ref_key = 'EXIF:GPSLatitudeRef'
self.longitude_ref_key = 'EXIF:GPSLongitudeRef'
self.original_name_key = 'XMP:OriginalFileName'
self.rating_key = 'XMP:Rating'
self.set_gps_ref = True
self.exif_metadata = None

Expand Down Expand Up @@ -116,6 +118,24 @@ def get_coordinate(self, type='latitude'):

return None

def get_description(self):
"""Get the description for a photo or video

:returns: str or None if no description is set or not a valid media type
"""
if(not self.is_valid()):
return None

exiftool_attributes = self.get_exiftool_attributes()

if exiftool_attributes is None:
return None

if(self.description_key not in exiftool_attributes):
return None

return exiftool_attributes[self.description_key]

def get_exiftool_attributes(self):
"""Get attributes for the media object from exiftool.

Expand Down Expand Up @@ -246,6 +266,24 @@ def set_date_taken(self, time):
self.reset_cache()
return status

def set_description(self, description):
"""Set description for a photo or video

:param str description: Description of the photo/video
:returns: bool
"""
if(not self.is_valid()):
return None

if(description is None):
return None

tags = {self.description_key: description}
status = self.__set_tags(tags)
self.reset_cache()

return status

def set_location(self, latitude, longitude):
if(not self.is_valid()):
return None
Expand Down Expand Up @@ -313,6 +351,40 @@ def set_title(self, title):

return status

def get_rating(self):
"""Get rating from EXIF

:returns: int or None if file invalid or no exif data
"""
if(not self.is_valid()):
return None

exiftool_attributes = self.get_exiftool_attributes()
if exiftool_attributes is None:
return None

if(self.rating_key not in exiftool_attributes):
return None

try:
return int(exiftool_attributes[self.rating_key])
except (ValueError, TypeError):
return None

def set_rating(self, rating):
"""Set rating for a photo

:param rating: Rating value or empty string to remove rating
:returns: bool
"""
if(not self.is_valid()):
return None

tags = {self.rating_key: rating}
status = self.__set_tags(tags)
self.reset_cache()
return status

def __set_tags(self, tags):
if(not self.is_valid()):
return None
Expand Down
2 changes: 1 addition & 1 deletion elodie/plugins/googlephotos/Readme.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![Build Status](https://travis-ci.org/jmathai/elodie.svg?branch=master)](https://travis-ci.org/jmathai/elodie) [![Coverage Status](https://coveralls.io/repos/github/jmathai/elodie/badge.svg?branch=master)](https://coveralls.io/github/jmathai/elodie?branch=master) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/jmathai/elodie/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/jmathai/elodie/?branch=master)

This plugin uploads all photos imported using Elodie to Google Photos. It was created after [Google Photos and Google Drive synchronization was deprecated](https://www.blog.google/products/photos/simplifying-google-photos-and-google-drive/). It aims to replicate my [workflow using Google Photos, Google Drive and Elodie](https://artplusmarketing.com/one-year-of-using-an-automated-photo-organization-and-archiving-workflow-89cf9ad7bddf).
This plugin uploads all photos imported using Elodie to Google Photos. It was created after [Google Photos and Google Drive synchronization was deprecated](https://www.blog.google/products/photos/simplifying-google-photos-and-google-drive/). It aims to replicate my [workflow using Google Photos, Google Drive and Elodie](https://medium.com/swlh/my-automated-photo-workflow-using-google-photos-and-elodie-afb753b8c724).

I didn't intend on it, but it turned out that with this plugin you can use Google Photos with Google Drive, iCloud Drive, Dropbox or no cloud storage service while still using Google Photos for viewing and experiencing your photo library.

Expand Down
138 changes: 138 additions & 0 deletions elodie/plugins/immich/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Immich Plugin (experimental)

This plugin enables albums, descriptions, location and favorites to be managed through Immich's UI while ensuring:

* All metadata is **persisted in the photo itself**
* Elodie remains the **canonical organizer**
* File moves do not break album or favorite state

Immich is treated as both an **intent source** (albums, descriptions, location favorites) and a **materialized view target** (albums rebuilt from metadata).

## Requirements

Install the plugin's requirements.

```bash
pip install -r elodie/plugins/immich/requirements.txt
```

## Configuration

Add the following section to your `config.ini` file:

```ini
[Plugins]
plugins=Immich

[Plugin Immich]
api_url=https://immich.mydomain.com/api
api_key=your_immich_api_key_here
external_library_path=/path/to/your/photo/library
```

### Configuration

* **api_url**: The API URL of your Immich instance (e.g., https://immich.mydomain.com/api)
* **api_key**: Your Immich API key for authentication. [Learn more](https://api.immich.app/authentication).
* **external_library_path**: The full path to your external library. [Learn more](https://docs.immich.app/guides/external-library).

## Usage

The plugin is automatically triggered when you run:

```bash
./elodie.py batch
```

Note: use the `--debug` flag to get verbose logs for troubleshooting.

### First Run (Bootstrap)

On the first run, the plugin will perform a **bootstrap sync** from Elodie to Immich:

* Scans all files in your photo library
* Reads album, description and rating metadata from photos
* Updates Immich albums, descriptions and favorites to match
* Marks bootstrap as completed

### Subsequent Runs (Incremental Sync)

After bootstrap, all runs perform **incremental sync** from Immich to Elodie:

* Fetches only assets updated since last sync
* Updates photo metadata for album and favorite changes
* Triggers Elodie file organization if metadata changed
* Updates last sync timestamp

Some updates result in Elodie renaming and moving files. This is translated by Immich as deleting a file and uploading a new one. In order to handle this gracefully, this plugin will wait until the new file is added to Immich's database and resolve the file move (i.e. adding the new file to albums the old file was in). Of course, it does all of this by using EXIF in the photo itself.

## Metadata Contracts

Metadata changes sync bidirectionally between your photos and Immich.

1. Updating fields through Immich will write them to the photo EXIF.
2. Updating a photo's EXIF will populate Immich.

The general intent of this plugin is that most metadata changes would happen through Immich and this plugin will ensure they get synced to the photo's EXIF.

### Albums

Uses existing Elodie album metadata:
* `XMP-xmpDM:Album` (preferred)
* `XMP:Album` (fallback)

#### Multiple Albums

Since Elodie translates an album to a folder, photos cannot exist in multiple albums.

However, Immich is able to support a photo belonging to multiple albums. And that's a great feature.

Here's how this plugin enables a single photo to be in multiple albums.
1. The XMP album field can contain multiple albums delimited by `;`.
2. If album is part of the folder path it will be named the `;` delimited value. For example, the album in EXIF and and name of the folder might be `Album 1;Album 2`.
3. The EXIF will be used to restore album memberships if the file gets moved.

### Favorites

Maps Immich favorites to XMP ratings:
* Immich `isFavorite = true` → `XMP:Rating = 5`
* Immich `isFavorite = false` → removes `XMP:Rating`

### Description

Description is stored in `XMP:Description` and maps the description field in Immich.

### Location

Location is stored in `XMP:GPSLatitude` and `XMP:GPSLongitude` and maps to the latitude and longitude fields in Immich.

## Error Handling

The plugin logs but does not crash on:
* Missing files
* Assets no longer managed by Elodie
* Album conflicts
* API failures

Summary output includes:
* Metadata updates
* Album moves
* Favorites set and cleared
* Error counts

## Limitations

* Immich asset IDs are not preserved across file moves
* No real-time or webhook-based sync
* Requires scheduled `./elodie.py batch` execution

## API Endpoints Used

The plugin uses the following Immich API endpoints:
* `GET /albums` - Get all albums ([docs](https://api.immich.app/endpoints/albums/getAllAlbums))
* `GET /albums/{id}` - Get album details with assets ([docs](https://api.immich.app/endpoints/albums/getAlbumInfo))
* `POST /albums` - Create new album ([docs](https://api.immich.app/endpoints/albums/createAlbum))
* `PUT /albums/{id}/assets` - Add assets to album ([docs](https://api.immich.app/endpoints/albums/addAssetsToAlbum))
* `POST /search/metadata` - Search assets by metadata ([docs](https://api.immich.app/endpoints/search/searchAssets))
* `GET /assets/{id}` - Get detailed asset information ([docs](https://api.immich.app/endpoints/assets/getAssetInfo))
* `PUT /assets/{id}` - Update asset (favorite status, description, location) ([docs](https://api.immich.app/endpoints/assets/updateAsset))
Empty file.
Loading