Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 `~/.elodie/config.ini` file:

```ini
[Plugins]
plugins=Immich

[PluginImmich]
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