• 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

88.32
/elodie/media/media.py
1
"""
2
The media module provides a base :class:`Media` class for media objects that
3
are tracked by Elodie. The Media class provides some base functionality used
4
by all the media types, but isn't itself used to represent anything. Its
5
sub-classes (:class:`~elodie.media.audio.Audio`,
6
:class:`~elodie.media.photo.Photo`, and :class:`~elodie.media.video.Video`)
7
are used to represent the actual files.
8

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

13
import os
1✔
14
import six
1✔
15

16
# load modules
17
from elodie.external.pyexiftool import ExifTool
1✔
18
from elodie.media.base import Base
1✔
19

20
class Media(Base):
1✔
21

22
    """The base class for all media objects.
23

24
    :param str source: The fully qualified path to the video file.
25
    """
26

27
    __name__ = 'Media'
1✔
28

29
    d_coordinates = {
1✔
30
        'latitude': 'latitude_ref',
31
        'longitude': 'longitude_ref'
32
    }
33

34
    def __init__(self, source=None):
1✔
35
        super(Media, self).__init__(source)
1✔
36
        self.exif_map = {
1✔
37
            'date_taken': [
38
                'EXIF:DateTimeOriginal',
39
                'EXIF:CreateDate',
40
                'EXIF:ModifyDate'
41
            ]
42
        }
43
        self.camera_make_keys = ['EXIF:Make', 'QuickTime:Make']
1✔
44
        self.camera_model_keys = ['EXIF:Model', 'QuickTime:Model']
1✔
45
        self.album_keys = ['XMP-xmpDM:Album', 'XMP:Album']
1✔
46
        self.title_key = 'XMP:Title'
1✔
47
        self.description_key = 'XMP:Description'
1✔
48
        self.latitude_keys = ['EXIF:GPSLatitude']
1✔
49
        self.longitude_keys = ['EXIF:GPSLongitude']
1✔
50
        self.latitude_ref_key = 'EXIF:GPSLatitudeRef'
1✔
51
        self.longitude_ref_key = 'EXIF:GPSLongitudeRef'
1✔
52
        self.original_name_key = 'XMP:OriginalFileName'
1✔
53
        self.rating_key = 'XMP:Rating'
1✔
54
        self.set_gps_ref = True
1✔
55
        self.exif_metadata = None
1✔
56

57
    def get_album(self):
1✔
58
        """Get album from EXIF
59

60
        :returns: None or string
61
        """
62
        if(not self.is_valid()):
1✔
63
            return None
×
64

65
        exiftool_attributes = self.get_exiftool_attributes()
1✔
66
        if exiftool_attributes is None:
1✔
67
            return None
×
68

69
        for album_key in self.album_keys:
1✔
70
            if album_key in exiftool_attributes:
1✔
71
                return exiftool_attributes[album_key]
1✔
72

73
        return None
1✔
74

75
    def get_coordinate(self, type='latitude'):
1✔
76
        """Get latitude or longitude of media from EXIF
77

78
        :param str type: Type of coordinate to get. Either "latitude" or
79
            "longitude".
80
        :returns: float or None if not present in EXIF or a non-photo file
81
        """
82

83
        exif = self.get_exiftool_attributes()
1✔
84
        if not exif:
1✔
85
            return None
×
86

87
        # The lat/lon _keys array has an order of precedence.
88
        # The first key is writable and we will give the writable
89
        #   key precence when reading.
90
        direction_multiplier = 1.0
1✔
91
        for key in self.latitude_keys + self.longitude_keys:
1✔
92
            if key not in exif:
1✔
93
                continue
1✔
94
            if isinstance(exif[key], six.string_types) and len(exif[key]) == 0:
1✔
95
                # If exiftool GPS output is empty, the data returned will be a str
96
                # with 0 length.
97
                # https://github.com/jmathai/elodie/issues/354
98
                continue
1✔
99

100
            # Cast coordinate to a float due to a bug in exiftool's
101
            #   -json output format.
102
            # https://github.com/jmathai/elodie/issues/171
103
            # http://u88.n24.queensu.ca/exiftool/forum/index.php/topic,7952.0.html  # noqa
104
            this_coordinate = float(exif[key])
1✔
105

106
            # TODO: verify that we need to check ref key
107
            #   when self.set_gps_ref != True
108
            if type == 'latitude' and key in self.latitude_keys:
1✔
109
                if self.latitude_ref_key in exif and \
1✔
110
                        exif[self.latitude_ref_key] == 'S':
111
                    direction_multiplier = -1.0
1✔
112
                return this_coordinate * direction_multiplier
1✔
113
            elif type == 'longitude' and key in self.longitude_keys:
1✔
114
                if self.longitude_ref_key in exif and \
1✔
115
                        exif[self.longitude_ref_key] == 'W':
116
                    direction_multiplier = -1.0
1✔
117
                return this_coordinate * direction_multiplier
1✔
118

119
        return None
1✔
120

121
    def get_description(self):
1✔
122
        """Get the description for a photo or video
123

124
        :returns: str or None if no description is set or not a valid media type
125
        """
126
        if(not self.is_valid()):
1✔
127
            return None
1✔
128

129
        exiftool_attributes = self.get_exiftool_attributes()
1✔
130

131
        if exiftool_attributes is None:
1✔
NEW
132
            return None
×
133

134
        if(self.description_key not in exiftool_attributes):
1✔
135
            return None
1✔
136

137
        return exiftool_attributes[self.description_key]
1✔
138

139
    def get_exiftool_attributes(self):
1✔
140
        """Get attributes for the media object from exiftool.
141

142
        :returns: dict, or False if exiftool was not available.
143
        """
144
        source = self.source
1✔
145

146
        #Cache exif metadata results and use if already exists for media
147
        if(self.exif_metadata is None):
1✔
148
            self.exif_metadata = ExifTool().get_metadata(source)
1✔
149

150
        if not self.exif_metadata:
1✔
151
            return False
×
152

153
        return self.exif_metadata
1✔
154

155
    def get_camera_make(self):
1✔
156
        """Get the camera make stored in EXIF.
157

158
        :returns: str
159
        """
160
        if(not self.is_valid()):
1✔
161
            return None
×
162

163
        exiftool_attributes = self.get_exiftool_attributes()
1✔
164

165
        if exiftool_attributes is None:
1✔
166
            return None
×
167

168
        for camera_make_key in self.camera_make_keys:
1✔
169
            if camera_make_key in exiftool_attributes:
1✔
170
                return exiftool_attributes[camera_make_key]
1✔
171

172
        return None
1✔
173

174
    def get_camera_model(self):
1✔
175
        """Get the camera make stored in EXIF.
176

177
        :returns: str
178
        """
179
        if(not self.is_valid()):
1✔
180
            return None
×
181

182
        exiftool_attributes = self.get_exiftool_attributes()
1✔
183

184
        if exiftool_attributes is None:
1✔
185
            return None
×
186

187
        for camera_model_key in self.camera_model_keys:
1✔
188
            if camera_model_key in exiftool_attributes:
1✔
189
                return exiftool_attributes[camera_model_key]
1✔
190

191
        return None
1✔
192

193
    def get_original_name(self):
1✔
194
        """Get the original name stored in EXIF.
195

196
        :returns: str
197
        """
198
        if(not self.is_valid()):
1✔
199
            return None
1✔
200

201
        exiftool_attributes = self.get_exiftool_attributes()
1✔
202

203
        if exiftool_attributes is None:
1✔
204
            return None
×
205

206
        if(self.original_name_key not in exiftool_attributes):
1✔
207
            return None
1✔
208

209
        return exiftool_attributes[self.original_name_key]
1✔
210

211
    def get_title(self):
1✔
212
        """Get the title for a photo of video
213

214
        :returns: str or None if no title is set or not a valid media type
215
        """
216
        if(not self.is_valid()):
1✔
217
            return None
×
218

219
        exiftool_attributes = self.get_exiftool_attributes()
1✔
220

221
        if exiftool_attributes is None:
1✔
222
            return None
×
223

224
        if(self.title_key not in exiftool_attributes):
1✔
225
            return None
1✔
226

227
        return exiftool_attributes[self.title_key]
1✔
228

229
    def reset_cache(self):
1✔
230
        """Resets any internal cache
231
        """
232
        self.exiftool_attributes = None
1✔
233
        self.exif_metadata = None
1✔
234
        super(Media, self).reset_cache()
1✔
235

236
    def set_album(self, album):
1✔
237
        """Set album for a photo
238

239
        :param str name: Name of album
240
        :returns: bool
241
        """
242
        if(not self.is_valid()):
1✔
243
            return None
1✔
244

245
        tags = {self.album_keys[0]: album}
1✔
246
        status = self.__set_tags(tags)
1✔
247
        self.reset_cache()
1✔
248

249
        return status
1✔
250

251
    def set_date_taken(self, time):
1✔
252
        """Set the date/time a photo was taken.
253

254
        :param datetime time: datetime object of when the photo was taken
255
        :returns: bool
256
        """
257
        if(time is None):
1✔
258
            return False
×
259

260
        tags = {}
1✔
261
        formatted_time = time.strftime('%Y:%m:%d %H:%M:%S')
1✔
262
        for key in self.exif_map['date_taken']:
1✔
263
            tags[key] = formatted_time
1✔
264

265
        status = self.__set_tags(tags)
1✔
266
        self.reset_cache()
1✔
267
        return status
1✔
268

269
    def set_description(self, description):
1✔
270
        """Set description for a photo or video
271

272
        :param str description: Description of the photo/video
273
        :returns: bool
274
        """
275
        if(not self.is_valid()):
1✔
NEW
276
            return None
×
277

278
        if(description is None):
1✔
279
            return None
1✔
280

281
        tags = {self.description_key: description}
1✔
282
        status = self.__set_tags(tags)
1✔
283
        self.reset_cache()
1✔
284

285
        return status
1✔
286

287
    def set_location(self, latitude, longitude):
1✔
288
        if(not self.is_valid()):
1✔
289
            return None
×
290

291
        # The lat/lon _keys array has an order of precedence.
292
        # The first key is writable and we will give the writable
293
        #   key precence when reading.
294
        tags = {
1✔
295
            self.latitude_keys[0]: latitude,
296
            self.longitude_keys[0]: longitude,
297
        }
298

299
        # If self.set_gps_ref == True then it means we are writing an EXIF
300
        #   GPS tag which requires us to set the reference key.
301
        # That's because the lat/lon are absolute values.
302
        if self.set_gps_ref:
1✔
303
            if latitude < 0:
1✔
304
                tags[self.latitude_ref_key] = 'S'
1✔
305

306
            if longitude < 0:
1✔
307
                tags[self.longitude_ref_key] = 'W'
1✔
308

309
        status = self.__set_tags(tags)
1✔
310
        self.reset_cache()
1✔
311

312
        return status
1✔
313

314
    def set_original_name(self, name=None):
1✔
315
        """Sets the original name EXIF tag if not already set.
316

317
        :returns: True, False, None
318
        """
319
        if(not self.is_valid()):
1✔
320
            return None
×
321

322
        # If EXIF original name tag is set then we return.
323
        if self.get_original_name() is not None:
1✔
324
            return None
1✔
325

326
        source = self.source
1✔
327

328
        if not name:
1✔
329
            name = os.path.basename(source)
1✔
330

331
        tags = {self.original_name_key: name}
1✔
332
        status = self.__set_tags(tags)
1✔
333
        self.reset_cache()
1✔
334
        return status
1✔
335

336
    def set_title(self, title):
1✔
337
        """Set title for a photo.
338

339
        :param str title: Title of the photo.
340
        :returns: bool
341
        """
342
        if(not self.is_valid()):
1✔
343
            return None
×
344

345
        if(title is None):
1✔
346
            return None
×
347

348
        tags = {self.title_key: title}
1✔
349
        status = self.__set_tags(tags)
1✔
350
        self.reset_cache()
1✔
351

352
        return status
1✔
353

354
    def get_rating(self):
1✔
355
        """Get rating from EXIF
356

357
        :returns: int or None if file invalid or no exif data
358
        """
359
        if(not self.is_valid()):
1✔
360
            return None
1✔
361

362
        exiftool_attributes = self.get_exiftool_attributes()
1✔
363
        if exiftool_attributes is None:
1✔
NEW
364
            return None
×
365

366
        if(self.rating_key not in exiftool_attributes):
1✔
367
            return None
1✔
368

369
        try:
1✔
370
            return int(exiftool_attributes[self.rating_key])
1✔
NEW
371
        except (ValueError, TypeError):
×
NEW
372
            return None
×
373

374
    def set_rating(self, rating):
1✔
375
        """Set rating for a photo
376
        
377
        :param rating: Rating value or empty string to remove rating
378
        :returns: bool
379
        """
380
        if(not self.is_valid()):
1✔
NEW
381
            return None
×
382

383
        tags = {self.rating_key: rating}
1✔
384
        status = self.__set_tags(tags)
1✔
385
        self.reset_cache()
1✔
386
        return status
1✔
387

388
    def __set_tags(self, tags):
1✔
389
        if(not self.is_valid()):
1✔
390
            return None
×
391

392
        source = self.source
1✔
393

394
        status = ''
1✔
395
        status = ExifTool().set_tags(tags,source)
1✔
396

397
        return status != ''
1✔
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