From c6d0df5e2820eae323f92d400ff4affafd0eb73e Mon Sep 17 00:00:00 2001 From: Thierry Perraut Date: Sat, 7 Feb 2026 14:36:57 -0800 Subject: [PATCH 1/2] Harden unicode/exif handling and sanitize generated paths (cherry picked from commit eafdf57ede0a2b9341f57c7461d54a3803c15b45) --- elodie/external/pyexiftool.py | 34 ++++++++++++++++++---- elodie/filesystem.py | 37 +++++++++++++++++++++++- elodie/media/media.py | 9 ++++-- elodie/tests/external_pyexiftool_test.py | 16 +++++++++- elodie/tests/filesystem_test.py | 22 ++++++++++++++ elodie/tests/media/media_test.py | 15 ++++++++++ 6 files changed, 122 insertions(+), 11 deletions(-) diff --git a/elodie/external/pyexiftool.py b/elodie/external/pyexiftool.py index 5adebbe6..29a07af1 100644 --- a/elodie/external/pyexiftool.py +++ b/elodie/external/pyexiftool.py @@ -337,7 +337,10 @@ def get_metadata_batch(self, filenames): The return value will have the format described in the documentation of :py:meth:`execute_json()`. """ - return self.execute_json(*filenames) + data = self.execute_json(*filenames) + if isinstance(data, list): + return data + return [] def get_metadata(self, filename): """Return meta-data for a single file. @@ -345,7 +348,12 @@ def get_metadata(self, filename): The returned dictionary has the format described in the documentation of :py:meth:`execute_json()`. """ - return self.execute_json(filename)[0] + data = self.execute_json(filename) + if not isinstance(data, list) or len(data) == 0: + return None + if not isinstance(data[0], dict): + return None + return data[0] def get_tags_batch(self, tags, filenames): """Return only specified tags for the given files. @@ -368,7 +376,10 @@ def get_tags_batch(self, tags, filenames): "an iterable of strings") params = ["-" + t for t in tags] params.extend(filenames) - return self.execute_json(*params) + data = self.execute_json(*params) + if isinstance(data, list): + return data + return [] def get_tags(self, tags, filename): """Return only specified tags for a single file. @@ -376,7 +387,10 @@ def get_tags(self, tags, filename): The returned dictionary has the format described in the documentation of :py:meth:`execute_json()`. """ - return self.get_tags_batch(tags, [filename])[0] + data = self.get_tags_batch(tags, [filename]) + if len(data) == 0: + return None + return data[0] def get_tag_batch(self, tag, filenames): """Extract a single tag from the given files. @@ -390,9 +404,14 @@ def get_tag_batch(self, tag, filenames): non-existent tags, in the same order as ``filenames``. """ data = self.get_tags_batch([tag], filenames) + if len(data) == 0: + return [None for _ in filenames] result = [] for d in data: - d.pop("SourceFile") + if not isinstance(d, dict): + result.append(None) + continue + d.pop("SourceFile", None) result.append(next(iter(d.values()), None)) return result @@ -402,7 +421,10 @@ def get_tag(self, tag, filename): The return value is the value of the specified tag, or ``None`` if this tag was not found in the file. """ - return self.get_tag_batch(tag, [filename])[0] + data = self.get_tag_batch(tag, [filename]) + if len(data) == 0: + return None + return data[0] def set_tags_batch(self, tags, filenames): """Writes the values of the specified tags for the given files. diff --git a/elodie/filesystem.py b/elodie/filesystem.py index 7f514101..7e1801b0 100644 --- a/elodie/filesystem.py +++ b/elodie/filesystem.py @@ -45,10 +45,39 @@ def __init__(self): # See build failures in Python3 here. # https://travis-ci.org/jmathai/elodie/builds/483012902 self.whitespace_regex = '[ \t\n\r\f\v]+' + # Disallow path separators and filesystem-invalid characters in a single path component. + self.invalid_path_component_regex = r'[<>:"/\\|?*\x00-\x1f]' + self.windows_reserved_names = { + 'CON', 'PRN', 'AUX', 'NUL', + 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', + 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9', + } # Instantiate a plugins object self.plugins = Plugins() + def sanitize_path_component(self, value): + """Sanitize a single folder/file path component for cross-platform safety.""" + if value is None: + return value + + value = re.sub(self.invalid_path_component_regex, '-', value) + + if os.sep: + value = value.replace(os.sep, '-') + if os.altsep: + value = value.replace(os.altsep, '-') + + value = value.rstrip(' .') + if len(value) == 0: + return '' + + # Windows has reserved device names which cannot be used as path components. + stem = value.split('.', 1)[0].upper() + if stem in self.windows_reserved_names: + value = '_%s' % value + + return value def _file_operation(self, operation_type, src, dst=None): """Perform file operation with dry-run support.""" if constants.dry_run: @@ -234,12 +263,16 @@ def get_file_name(self, metadata): name, ) else: + this_value = self.sanitize_path_component(this_value) name = re.sub( '%{}'.format(part), this_value, name, ) + # Final guard to avoid unsafe separators from custom templates. + name = self.sanitize_path_component(name) + config = load_config() if('File' in config and 'capitalization' in config['File'] and config['File']['capitalization'] == 'upper'): @@ -385,7 +418,9 @@ def get_folder_path(self, metadata, path_parts=None): part, mask = this_part this_path = self.get_dynamic_path(part, mask, metadata) if this_path: - path.append(this_path.strip()) + this_path = self.sanitize_path_component(this_path).strip() + if len(this_path) > 0: + path.append(this_path) # We break as soon as we have a value to append # Else we continue for fallbacks break diff --git a/elodie/media/media.py b/elodie/media/media.py index 788b670d..a862ca86 100644 --- a/elodie/media/media.py +++ b/elodie/media/media.py @@ -119,16 +119,19 @@ def get_coordinate(self, type='latitude'): def get_exiftool_attributes(self): """Get attributes for the media object from exiftool. - :returns: dict, or False if exiftool was not available. + :returns: dict, or None if exiftool metadata was unavailable. """ source = self.source #Cache exif metadata results and use if already exists for media if(self.exif_metadata is None): - self.exif_metadata = ExifTool().get_metadata(source) + try: + self.exif_metadata = ExifTool().get_metadata(source) + except Exception: + self.exif_metadata = None if not self.exif_metadata: - return False + return None return self.exif_metadata diff --git a/elodie/tests/external_pyexiftool_test.py b/elodie/tests/external_pyexiftool_test.py index 1cd2a458..a51b5d11 100644 --- a/elodie/tests/external_pyexiftool_test.py +++ b/elodie/tests/external_pyexiftool_test.py @@ -81,4 +81,18 @@ def test_exiftool_with_non_ascii_file(): if os.path.exists(test_file): os.remove(test_file) if os.path.exists(test_dir): - os.rmdir(test_dir) \ No newline at end of file + os.rmdir(test_dir) + +def test_get_metadata_returns_none_when_execute_json_fails(): + """get_metadata() should not crash when execute_json returns None.""" + et = ExifTool() + with patch.object(et, 'execute_json', return_value=None): + result = et.get_metadata("/tmp/test.jpg") + assert result is None + +def test_get_metadata_returns_none_when_execute_json_is_empty(): + """get_metadata() should not crash when execute_json returns an empty list.""" + et = ExifTool() + with patch.object(et, 'execute_json', return_value=[]): + result = et.get_metadata("/tmp/test.jpg") + assert result is None diff --git a/elodie/tests/filesystem_test.py b/elodie/tests/filesystem_test.py index c5cf9c2e..c62c2686 100644 --- a/elodie/tests/filesystem_test.py +++ b/elodie/tests/filesystem_test.py @@ -251,6 +251,20 @@ def test_get_file_name_with_uppercase_and_spaces(): assert file_name == helper.path_tz_fix('2015-12-05_00-59-26-plain-with-spaces-and-uppercase-123.jpg'), file_name +def test_get_file_name_sanitizes_invalid_path_characters(): + filesystem = FileSystem() + media = Photo(helper.get_file('with-title.jpg')) + metadata = media.get_metadata() + metadata['title'] = 'nami cc aapi / 中文部公众讲座 : 1?*' + + file_name = filesystem.get_file_name(metadata) + + assert '/' not in file_name, file_name + assert '\\' not in file_name, file_name + assert ':' not in file_name, file_name + assert '?' not in file_name, file_name + assert '*' not in file_name, file_name + @mock.patch('elodie.config.get_config_file', return_value='%s/config.ini-filename-custom' % gettempdir()) def test_get_file_name_custom(mock_get_config_file): with open(mock_get_config_file.return_value, 'w') as f: @@ -395,6 +409,14 @@ def test_get_folder_path_with_location(): assert path == os.path.join('2015-12-Dec','Sunnyvale'), path +@mock.patch('elodie.filesystem.geolocation.place_name', return_value={'default': u'Bellevue/WA', 'city': u'Bellevue/WA'}) +def test_get_folder_path_sanitizes_location_separator(mock_place_name): + filesystem = FileSystem() + media = Photo(helper.get_file('with-location.jpg')) + path = filesystem.get_folder_path(media.get_metadata()) + + assert path == os.path.join('2015-12-Dec', 'Bellevue-WA'), path + @mock.patch('elodie.config.get_config_file', return_value='%s/config.ini-original-with-camera-make-and-model' % gettempdir()) def test_get_folder_path_with_camera_make_and_model(mock_get_config_file): with open(mock_get_config_file.return_value, 'w') as f: diff --git a/elodie/tests/media/media_test.py b/elodie/tests/media/media_test.py index f435bb45..871a81ba 100644 --- a/elodie/tests/media/media_test.py +++ b/elodie/tests/media/media_test.py @@ -9,6 +9,7 @@ import string import tempfile import time +from unittest.mock import patch sys.path.insert(0, os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))))) sys.path.insert(0, os.path.abspath(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))) @@ -18,6 +19,7 @@ from elodie.media.media import Media from elodie.media.photo import Photo from elodie.media.video import Video +from elodie.external.pyexiftool import ExifTool os.environ['TZ'] = 'GMT' @@ -89,6 +91,19 @@ def test_get_original_name_invalid_file(): assert original_name is None, original_name +def test_get_original_name_when_exiftool_metadata_is_unavailable(): + temporary_folder, folder = helper.create_working_folder() + + origin = '%s/%s' % (folder, 'plain.jpg') + file = helper.get_file('plain.jpg') + shutil.copyfile(file, origin) + + media = Media.get_class_by_file(origin, [Photo]) + with patch.object(ExifTool, 'get_metadata', return_value=None): + original_name = media.get_original_name() + + assert original_name is None, original_name + def test_set_original_name_when_exists(): temporary_folder, folder = helper.create_working_folder() From 6efc1b75f67ff338d9a8fdbe996cc1dbacdcfaa5 Mon Sep 17 00:00:00 2001 From: Thierry Perraut Date: Sat, 7 Feb 2026 14:38:07 -0800 Subject: [PATCH 2/2] Support pre-epoch media dates safely (cherry picked from commit ed37bf1e03c860aafbd9c5e57a6f84c05389f02e) --- elodie/filesystem.py | 49 ++++++++++++++++++++++---------- elodie/media/photo.py | 10 +++---- elodie/media/text.py | 6 +++- elodie/media/video.py | 38 ++++++++++++------------- elodie/tests/filesystem_test.py | 23 ++++++++++++++- elodie/tests/media/photo_test.py | 13 +++++++++ elodie/tests/media/text_test.py | 19 +++++++++++++ elodie/tests/media/video_test.py | 13 +++++++++ 8 files changed, 129 insertions(+), 42 deletions(-) diff --git a/elodie/filesystem.py b/elodie/filesystem.py index 7e1801b0..fb18eefd 100644 --- a/elodie/filesystem.py +++ b/elodie/filesystem.py @@ -10,6 +10,7 @@ import re import shutil import time +import calendar from send2trash import send2trash from elodie import compatability @@ -78,6 +79,25 @@ def sanitize_path_component(self, value): value = '_%s' % value return value + + def _safe_timestamp(self, date_taken): + """Convert struct_time to timestamp with a pre-epoch fallback.""" + try: + return time.mktime(date_taken) + except (OverflowError, OSError, ValueError): + try: + return calendar.timegm(date_taken) + except (OverflowError, OSError, ValueError, TypeError): + return None + + def _safe_set_mtime(self, file_path, mtime): + """Set file mtime without crashing on unsupported timestamps.""" + try: + os.utime(file_path, (time.time(), mtime)) + return True + except (OverflowError, OSError, ValueError): + log.warn('Unable to set mtime for %s using %s' % (file_path, mtime)) + return False def _file_operation(self, operation_type, src, dst=None): """Perform file operation with dry-run support.""" if constants.dry_run: @@ -237,7 +257,7 @@ def get_file_name(self, metadata): # This helps when re-running the program on file # which were already processed. this_value = re.sub( - '^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-', + r'^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-', '', metadata['base_name'] ) @@ -314,7 +334,7 @@ def get_file_name_definition(self): # name. # I.e. %date-%original_name-%title.%extension => ['date', 'original_name', 'title', 'extension'] #noqa path_parts = re.findall( - '(\%[a-z_]+)', + r'(%[a-z_]+)', config_file['name'] ) @@ -374,7 +394,7 @@ def get_folder_path_definition(self): # I.e. %foo/%bar => ['foo', 'bar'] # I.e. %foo/%bar|%example|"something" => ['foo', 'bar|example|"something"'] path_parts = re.findall( - '(\%[^/]+)', + r'(%[^/]+)', config_directory['full_path'] ) @@ -679,7 +699,7 @@ def set_utime_from_metadata(self, metadata, file_path): date_taken = metadata['date_taken'] base_name = metadata['base_name'] year_month_day_match = re.search( - '^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})', + r'^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})', base_name ) if(year_month_day_match is not None): @@ -689,18 +709,17 @@ def set_utime_from_metadata(self, metadata, file_path): '%Y-%m-%d %H:%M:%S' ) - if not constants.dry_run: - os.utime(file_path, (time.time(), time.mktime(date_taken))) - else: - print(f"[DRY-RUN] Would set utime from date pattern for: {file_path}") + date_taken_in_seconds = self._safe_timestamp(date_taken) + if(date_taken_in_seconds is None): + log.warn('Could not convert date_taken to timestamp for %s' % file_path) + return + + if not constants.dry_run: + self._safe_set_mtime(file_path, date_taken_in_seconds) + elif year_month_day_match is not None: + print(f"[DRY-RUN] Would set utime from date pattern for: {file_path}") else: - # We don't make any assumptions about time zones and - # assume local time zone. - date_taken_in_seconds = time.mktime(date_taken) - if not constants.dry_run: - os.utime(file_path, (time.time(), (date_taken_in_seconds))) - else: - print(f"[DRY-RUN] Would set utime from metadata for: {file_path}") + print(f"[DRY-RUN] Would set utime from metadata for: {file_path}") def should_exclude(self, path, regex_list=set(), needs_compiled=False): if(len(regex_list) == 0): diff --git a/elodie/media/photo.py b/elodie/media/photo.py index 1ccd3ff6..30c650c3 100644 --- a/elodie/media/photo.py +++ b/elodie/media/photo.py @@ -55,7 +55,7 @@ def get_date_taken(self): exif = self.get_exiftool_attributes() if not exif: - return seconds_since_epoch + return time.gmtime(seconds_since_epoch) # We need to parse a string from EXIF into a timestamp. # EXIF DateTimeOriginal and EXIF DateTime are both stored @@ -71,10 +71,10 @@ def get_date_taken(self): dt, tm = exif[key].split(' ') dt_list = compile(r'-|:').split(dt) dt_list = dt_list + compile(r'-|:').split(tm) - dt_list = map(int, dt_list) - time_tuple = datetime(*dt_list).timetuple() - seconds_since_epoch = time.mktime(time_tuple) - break + dt_list = list(map(int, dt_list)) + # Build a struct_time directly from EXIF to support + # pre-epoch dates on platforms where mktime can fail. + return datetime(*dt_list).utctimetuple() except BaseException as e: log.error(e) pass diff --git a/elodie/media/text.py b/elodie/media/text.py index f8400e01..bc34385c 100644 --- a/elodie/media/text.py +++ b/elodie/media/text.py @@ -6,6 +6,7 @@ """ from json import dumps, loads +import calendar import os from shutil import copy2, copyfileobj import time @@ -106,7 +107,10 @@ def set_date_taken(self, passed_in_time): if(time is None): return False - seconds_since_epoch = time.mktime(passed_in_time.timetuple()) + try: + seconds_since_epoch = time.mktime(passed_in_time.timetuple()) + except (OverflowError, OSError, ValueError): + seconds_since_epoch = calendar.timegm(passed_in_time.utctimetuple()) status = self.write_metadata(date_taken=seconds_since_epoch) self.reset_cache() return status diff --git a/elodie/media/video.py b/elodie/media/video.py index a53d238a..affb8375 100644 --- a/elodie/media/video.py +++ b/elodie/media/video.py @@ -9,7 +9,7 @@ from __future__ import division # load modules -from datetime import datetime +from datetime import datetime, timezone import os import re @@ -66,8 +66,16 @@ def get_date_taken(self): source = self.source seconds_since_epoch = min(os.path.getmtime(source), os.path.getctime(source)) # noqa + fallback_date = datetime.fromtimestamp(seconds_since_epoch, timezone.utc) + best_date = fallback_date + found_exif_date = False exif = self.get_exiftool_attributes() + if not exif: + if(seconds_since_epoch == 0): + return None + return fallback_date.utctimetuple() + for date_key in self.exif_map['date_taken']: if date_key in exif: # Example date strings we want to parse @@ -76,28 +84,18 @@ def get_date_taken(self): date = re.search('([0-9: ]+)([-+][0-9:]+)?', exif[date_key]) if(date is not None): date_string = date.group(1) - date_offset = date.group(2) try: - exif_seconds_since_epoch = time.mktime( - datetime.strptime( - date_string, - '%Y:%m:%d %H:%M:%S' - ).timetuple() - ) - if(exif_seconds_since_epoch < seconds_since_epoch): - seconds_since_epoch = exif_seconds_since_epoch - if date_offset is not None: - offset_parts = date_offset[1:].split(':') - offset_seconds = int(offset_parts[0]) * 3600 - offset_seconds = offset_seconds + int(offset_parts[1]) * 60 # noqa - if date_offset[0] == '-': - seconds_since_epoch - offset_seconds - elif date_offset[0] == '+': - seconds_since_epoch + offset_seconds + exif_date = datetime.strptime( + date_string, + '%Y:%m:%d %H:%M:%S' + ).replace(tzinfo=timezone.utc) + if(exif_date < best_date): + best_date = exif_date + found_exif_date = True except: pass - if(seconds_since_epoch == 0): + if(seconds_since_epoch == 0 and found_exif_date is False): return None - return time.gmtime(seconds_since_epoch) + return best_date.utctimetuple() diff --git a/elodie/tests/filesystem_test.py b/elodie/tests/filesystem_test.py index c62c2686..35de754c 100644 --- a/elodie/tests/filesystem_test.py +++ b/elodie/tests/filesystem_test.py @@ -1160,6 +1160,27 @@ def test_set_utime_without_exif_date(): assert final_stat.st_mtime == time.mktime(metadata_final['date_taken']), (final_stat.st_mtime, time.mktime(metadata_final['date_taken'])) assert initial_checksum == final_checksum +@mock.patch('elodie.filesystem.os.utime', side_effect=OSError()) +@mock.patch('elodie.filesystem.time.mktime', side_effect=OverflowError()) +def test_set_utime_with_pre_epoch_date_does_not_crash(mock_mktime, mock_utime): + filesystem = FileSystem() + temporary_folder, folder = helper.create_working_folder() + + origin = os.path.join(folder, 'photo.jpg') + shutil.copyfile(helper.get_file('plain.jpg'), origin) + + metadata = { + 'date_taken': (1960, 1, 4, 11, 22, 33, 0, 4, 0), + 'base_name': 'photo' + } + + filesystem.set_utime_from_metadata(metadata, origin) + + shutil.rmtree(folder) + + assert mock_mktime.called + assert mock_utime.called + def test_should_exclude_with_no_exclude_arg(): filesystem = FileSystem() result = filesystem.should_exclude('/some/path') @@ -1187,7 +1208,7 @@ def test_should_exclude_with_multiple_with_one_matching_regex(): def test_should_exclude_with_complex_matching_regex(): filesystem = FileSystem() - result = filesystem.should_exclude('/var/folders/j9/h192v5v95gd_fhpv63qzyd1400d9ct/T/T497XPQH2R/UATR2GZZTX/2016-04-Apr/London/2016-04-07_11-15-26-valid-sample-title.txt', {re.compile('London.*\.txt$')}) + result = filesystem.should_exclude('/var/folders/j9/h192v5v95gd_fhpv63qzyd1400d9ct/T/T497XPQH2R/UATR2GZZTX/2016-04-Apr/London/2016-04-07_11-15-26-valid-sample-title.txt', {re.compile(r'London.*\.txt$')}) assert result == True, result @mock.patch('elodie.config.get_config_file', return_value='%s/config.ini-does-not-exist' % gettempdir()) diff --git a/elodie/tests/media/photo_test.py b/elodie/tests/media/photo_test.py index 4b65d9d7..ef597223 100644 --- a/elodie/tests/media/photo_test.py +++ b/elodie/tests/media/photo_test.py @@ -8,6 +8,7 @@ import shutil import tempfile import time +from unittest.mock import patch import pytest @@ -138,6 +139,18 @@ def test_get_date_taken_without_exif(): assert date_taken == date_taken_from_file, date_taken +def test_get_date_taken_before_epoch(): + photo = Photo(helper.get_file('plain.jpg')) + + with patch.object(Photo, 'get_exiftool_attributes', return_value={ + 'EXIF:DateTimeOriginal': '1960:01:04 11:22:33' + }): + # Simulate platforms where mktime fails for pre-epoch dates. + with patch('elodie.media.photo.time.mktime', side_effect=OverflowError()): + date_taken = photo.get_date_taken() + + assert date_taken == (1960, 1, 4, 11, 22, 33, 0, 4, 0), date_taken + def test_get_camera_make(): photo = Photo(helper.get_file('with-location.jpg')) make = photo.get_camera_make() diff --git a/elodie/tests/media/text_test.py b/elodie/tests/media/text_test.py index 245284cf..115ee40f 100644 --- a/elodie/tests/media/text_test.py +++ b/elodie/tests/media/text_test.py @@ -7,6 +7,7 @@ import shutil import tempfile import time +from unittest.mock import patch import pytest @@ -155,6 +156,24 @@ def test_set_date_taken(): assert helper.time_convert((2013, 9, 30, 7, 6, 5, 0, 273, 0)) == metadata_new['date_taken'], metadata_new['date_taken'] +def test_set_date_taken_before_epoch(): + temporary_folder, folder = helper.create_working_folder() + + origin = '%s/text.txt' % folder + shutil.copyfile(helper.get_file('valid.txt'), origin) + + text = Text(origin) + + with patch('elodie.media.text.time.mktime', side_effect=OverflowError()): + status = text.set_date_taken(datetime(1960, 1, 4, 7, 6, 5)) + assert status == True, status + + metadata_new = Text(origin).get_metadata() + + shutil.rmtree(folder) + + assert metadata_new['date_taken'][0] == 1960, metadata_new['date_taken'] + def test_set_location(): temporary_folder, folder = helper.create_working_folder() diff --git a/elodie/tests/media/video_test.py b/elodie/tests/media/video_test.py index 759b39f4..76e06b1a 100644 --- a/elodie/tests/media/video_test.py +++ b/elodie/tests/media/video_test.py @@ -7,6 +7,7 @@ import tempfile import time import datetime +from unittest.mock import patch sys.path.insert(0, os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))))) sys.path.insert(0, os.path.abspath(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))) @@ -72,6 +73,18 @@ def test_get_date_taken(): assert date_taken == (2015, 1, 19, 12, 45, 11, 0, 19, 0), date_taken +def test_get_date_taken_before_epoch(): + video = Video(helper.get_file('video.mov')) + + with patch.object(Video, 'get_exiftool_attributes', return_value={ + 'QuickTime:CreationDate': '1960:01:04 11:22:33' + }): + # Simulate platforms where mktime fails for pre-epoch dates. + with patch('elodie.media.video.time.mktime', side_effect=OverflowError()): + date_taken = video.get_date_taken() + + assert date_taken == (1960, 1, 4, 11, 22, 33, 0, 4, 0), date_taken + def test_get_exiftool_attributes(): video = Video(helper.get_file('video.mov')) exif = video.get_exiftool_attributes()