memories-classify 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- classify/__init__.py +1 -0
- classify/__main__.py +6 -0
- classify/classify.py +70 -0
- classify/const.py +23 -0
- classify/exception.py +9 -0
- classify/logger.py +30 -0
- classify/main.py +70 -0
- classify/processors/__init__.py +0 -0
- classify/processors/files.py +200 -0
- classify/processors/image.py +79 -0
- classify/processors/video.py +370 -0
- classify/settings.py +190 -0
- memories_classify-1.2.0.dist-info/METADATA +87 -0
- memories_classify-1.2.0.dist-info/RECORD +18 -0
- memories_classify-1.2.0.dist-info/WHEEL +5 -0
- memories_classify-1.2.0.dist-info/entry_points.txt +2 -0
- memories_classify-1.2.0.dist-info/licenses/LICENSE +674 -0
- memories_classify-1.2.0.dist-info/top_level.txt +1 -0
classify/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Init python."""
|
classify/__main__.py
ADDED
classify/classify.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Main function to classify pictures and videos."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from tqdm import tqdm
|
|
8
|
+
from tqdm.contrib.logging import logging_redirect_tqdm
|
|
9
|
+
|
|
10
|
+
from .processors.files import FileProcessor
|
|
11
|
+
from .processors.image import ImageProcessor
|
|
12
|
+
from .processors.video import VideoProcessor
|
|
13
|
+
from .settings import ClassifySettings
|
|
14
|
+
|
|
15
|
+
_LOGGER = logging.getLogger("classify")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Classify:
|
|
19
|
+
"""Classify global class."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, settings: ClassifySettings):
|
|
22
|
+
"""Initialize the class."""
|
|
23
|
+
self.settings = settings
|
|
24
|
+
self.fp = FileProcessor(settings=settings)
|
|
25
|
+
self.ip = ImageProcessor(settings=settings, file_processor=self.fp)
|
|
26
|
+
self.vp = VideoProcessor(settings=settings, file_processor=self.fp)
|
|
27
|
+
|
|
28
|
+
def run(self) -> None:
|
|
29
|
+
"""Classify pictures and videos."""
|
|
30
|
+
|
|
31
|
+
if not self.fp.pictures and not self.fp.videos:
|
|
32
|
+
_LOGGER.info("No pictures or videos found")
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
_LOGGER.info(
|
|
36
|
+
"##### Clean Android Google Photo trashed and pending pictures uploaded #####"
|
|
37
|
+
)
|
|
38
|
+
self.fp.delete_android_trash_files()
|
|
39
|
+
|
|
40
|
+
self.process_files("picture", self.fp.pictures, self.ip.process)
|
|
41
|
+
self.process_files("video", self.fp.videos, self.vp.process)
|
|
42
|
+
|
|
43
|
+
_LOGGER.info("")
|
|
44
|
+
|
|
45
|
+
def process_files(
|
|
46
|
+
self, kind: str, paths: list[str], process: Callable[[str], None]
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Process files of a kind, logging errors without stopping."""
|
|
49
|
+
if not paths:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
_LOGGER.info("")
|
|
53
|
+
_LOGGER.info("##### %ss #####", kind.capitalize())
|
|
54
|
+
# Iterate on a copy, processing may update the file lists
|
|
55
|
+
with (
|
|
56
|
+
logging_redirect_tqdm(loggers=[_LOGGER]),
|
|
57
|
+
tqdm(list(paths), desc=f"{kind.capitalize()}s", unit=kind) as progress,
|
|
58
|
+
):
|
|
59
|
+
for path in progress:
|
|
60
|
+
_LOGGER.debug(
|
|
61
|
+
"Process %s %s (%s GB)",
|
|
62
|
+
kind,
|
|
63
|
+
path,
|
|
64
|
+
round(os.path.getsize(path) / 1e9, 3),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
process(path)
|
|
69
|
+
except Exception as exc: # noqa: BLE001
|
|
70
|
+
_LOGGER.error("Error processing %s %s: %s", kind, path, exc)
|
classify/const.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Constants."""
|
|
2
|
+
|
|
3
|
+
PICTURE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff"]
|
|
4
|
+
VIDEO_EXTENSIONS = [".mp4", ".mov", ".avi", ".mkv", ".webm"]
|
|
5
|
+
|
|
6
|
+
VIDEO_CODEC = "hevc"
|
|
7
|
+
|
|
8
|
+
FILENAME_DATE_FORMATS = {
|
|
9
|
+
r"(\d{8}_\d{9})": "%Y%m%d_%H%M%S%f",
|
|
10
|
+
r"(\d{8}-\d{9})": "%Y%m%d-%H%M%S%f",
|
|
11
|
+
r"(\d{14})": "%Y%m%d%H%M%S",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
DEFAULT_NAME_FORMAT = "%Y-%m-%d-%Hh%Mm%S"
|
|
15
|
+
|
|
16
|
+
DEFAULT_COMMENT_MESSAGE = "Processed by memories-classify"
|
|
17
|
+
|
|
18
|
+
DEFAULT_VIDEO_BITRATE_MBPS_LIMIT = 30
|
|
19
|
+
|
|
20
|
+
DEFAULT_FFMPEG_PATH = "ffmpeg"
|
|
21
|
+
DEFAULT_FFMPEG_INPUT_EXTRA_ARGS = ""
|
|
22
|
+
DEFAULT_FFMPEG_OUTPUT_EXTRA_ARGS = ""
|
|
23
|
+
DEFAULT_FFPROBE_PATH = "ffprobe"
|
classify/exception.py
ADDED
classify/logger.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Custom logger module"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CustomFormatter(logging.Formatter):
|
|
8
|
+
"""Custom log formatter"""
|
|
9
|
+
|
|
10
|
+
grey = "\x1b[90;20m"
|
|
11
|
+
white = "\x1b[97;20m"
|
|
12
|
+
yellow = "\x1b[33;20m"
|
|
13
|
+
red = "\x1b[31;20m"
|
|
14
|
+
bold_red = "\x1b[31;1m"
|
|
15
|
+
reset = "\x1b[0m"
|
|
16
|
+
log_format = "[%(asctime)s][%(levelname)s] %(message)s"
|
|
17
|
+
|
|
18
|
+
FORMATS: ClassVar[dict[int, str]] = {
|
|
19
|
+
logging.DEBUG: grey + log_format + reset,
|
|
20
|
+
logging.INFO: white + log_format + reset,
|
|
21
|
+
logging.WARNING: yellow + log_format + reset,
|
|
22
|
+
logging.ERROR: red + log_format + reset,
|
|
23
|
+
logging.CRITICAL: bold_red + log_format + reset,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
27
|
+
"""Format log record"""
|
|
28
|
+
log_fmt = self.FORMATS.get(record.levelno)
|
|
29
|
+
formatter = logging.Formatter(log_fmt)
|
|
30
|
+
return formatter.format(record)
|
classify/main.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Classify."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from subprocess import DEVNULL, CalledProcessError, check_call
|
|
7
|
+
|
|
8
|
+
from .classify import Classify
|
|
9
|
+
from .exception import ClassifyException
|
|
10
|
+
from .logger import CustomFormatter
|
|
11
|
+
from .settings import ClassifySettings, parse_args
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger("classify")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_tool_available(path: str) -> bool:
|
|
17
|
+
"""Check that an ffmpeg tool can be executed."""
|
|
18
|
+
try:
|
|
19
|
+
check_call([path, "-version"], stdout=DEVNULL, stderr=DEVNULL)
|
|
20
|
+
except OSError, CalledProcessError:
|
|
21
|
+
return False
|
|
22
|
+
return True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(arg_list: list[str] | None = None):
|
|
26
|
+
"""Call from cli."""
|
|
27
|
+
|
|
28
|
+
args = parse_args(arg_list)
|
|
29
|
+
|
|
30
|
+
if not _LOGGER.handlers:
|
|
31
|
+
handler = logging.StreamHandler()
|
|
32
|
+
handler.setFormatter(CustomFormatter())
|
|
33
|
+
_LOGGER.addHandler(handler)
|
|
34
|
+
_LOGGER.setLevel(logging.DEBUG if args.verbose else logging.INFO)
|
|
35
|
+
_LOGGER.info("Classify pictures and videos tool")
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
settings = ClassifySettings(args=args)
|
|
39
|
+
except ClassifyException as exc:
|
|
40
|
+
_LOGGER.error("%s", exc)
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
|
|
43
|
+
if settings.dry_run:
|
|
44
|
+
_LOGGER.warning("Dry run mode activated")
|
|
45
|
+
|
|
46
|
+
if not os.path.isdir(settings.directory):
|
|
47
|
+
_LOGGER.error("Directory %s does not exist", settings.directory)
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
|
|
50
|
+
_LOGGER.info("Process directory: %s", settings.directory)
|
|
51
|
+
|
|
52
|
+
for tool_path, option in (
|
|
53
|
+
(settings.ffmpeg_path, "--ffmpeg-path"),
|
|
54
|
+
(settings.ffprobe_path, "--ffprobe-path"),
|
|
55
|
+
):
|
|
56
|
+
if not is_tool_available(tool_path):
|
|
57
|
+
_LOGGER.error(
|
|
58
|
+
"%s not found (install ffmpeg or set path with %s)", tool_path, option
|
|
59
|
+
)
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
Classify(settings).run()
|
|
64
|
+
_LOGGER.info("End")
|
|
65
|
+
except ClassifyException as exc:
|
|
66
|
+
_LOGGER.error("End with error: %s", exc)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
except KeyboardInterrupt:
|
|
69
|
+
_LOGGER.warning("Interrupted")
|
|
70
|
+
sys.exit(130)
|
|
File without changes
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Files management for Classify."""
|
|
2
|
+
|
|
3
|
+
import filecmp
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import string
|
|
9
|
+
from collections.abc import Callable, Iterator
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
|
|
12
|
+
from classify.settings import ClassifySettings
|
|
13
|
+
|
|
14
|
+
from ..const import FILENAME_DATE_FORMATS, PICTURE_EXTENSIONS, VIDEO_EXTENSIONS
|
|
15
|
+
from ..exception import ClassifyException
|
|
16
|
+
|
|
17
|
+
_LOGGER = logging.getLogger("classify")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FileProcessor:
|
|
21
|
+
"""Files processor for Classify."""
|
|
22
|
+
|
|
23
|
+
pictures: list[str]
|
|
24
|
+
videos: list[str]
|
|
25
|
+
|
|
26
|
+
def __init__(self, settings: ClassifySettings) -> None:
|
|
27
|
+
"""Init."""
|
|
28
|
+
self.settings = settings
|
|
29
|
+
self.pictures = []
|
|
30
|
+
self.videos = []
|
|
31
|
+
|
|
32
|
+
if not os.path.exists(self.settings.output):
|
|
33
|
+
_LOGGER.info("Create missing output directory %s", self.settings.output)
|
|
34
|
+
if not self.settings.dry_run:
|
|
35
|
+
os.makedirs(self.settings.output)
|
|
36
|
+
|
|
37
|
+
self.reload()
|
|
38
|
+
|
|
39
|
+
_LOGGER.info(
|
|
40
|
+
"Found %d pictures and %d videos",
|
|
41
|
+
len(self.pictures),
|
|
42
|
+
len(self.videos),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def reload(self) -> None:
|
|
46
|
+
"""Reload files from a directory."""
|
|
47
|
+
output_path = os.path.abspath(self.settings.output)
|
|
48
|
+
for root, dirs, files in os.walk(self.settings.directory):
|
|
49
|
+
# Do not process again files classified in an output inside the input
|
|
50
|
+
dirs[:] = [
|
|
51
|
+
directory
|
|
52
|
+
for directory in dirs
|
|
53
|
+
if os.path.abspath(os.path.join(root, directory)) != output_path
|
|
54
|
+
]
|
|
55
|
+
for file in files:
|
|
56
|
+
file_path = os.path.join(root, file)
|
|
57
|
+
file_relpath = os.path.relpath(file_path, self.settings.directory)
|
|
58
|
+
if any(
|
|
59
|
+
re.match(pattern, file_relpath) for pattern in self.settings.exclude
|
|
60
|
+
):
|
|
61
|
+
_LOGGER.info("Exclude %s because of exclude pattern", file_relpath)
|
|
62
|
+
continue
|
|
63
|
+
file_extension = os.path.splitext(file)[1].lower()
|
|
64
|
+
if file_extension in PICTURE_EXTENSIONS:
|
|
65
|
+
self.pictures.append(file_path)
|
|
66
|
+
elif file_extension in VIDEO_EXTENSIONS:
|
|
67
|
+
self.videos.append(file_path)
|
|
68
|
+
|
|
69
|
+
def remove_file(self, file_path: str) -> None:
|
|
70
|
+
"""Delete a file from disk and drop it from the lists to process."""
|
|
71
|
+
if not self.settings.dry_run and os.path.exists(file_path):
|
|
72
|
+
_LOGGER.debug("Remove %s", file_path)
|
|
73
|
+
os.remove(file_path)
|
|
74
|
+
if file_path in self.pictures:
|
|
75
|
+
self.pictures.remove(file_path)
|
|
76
|
+
elif file_path in self.videos:
|
|
77
|
+
self.videos.remove(file_path)
|
|
78
|
+
else:
|
|
79
|
+
raise ClassifyException(f"File {file_path} not found in the list")
|
|
80
|
+
|
|
81
|
+
def get_output_path(self, file: str) -> str:
|
|
82
|
+
"""Get the output path for a file."""
|
|
83
|
+
relpath = os.path.dirname(os.path.relpath(file, self.settings.directory))
|
|
84
|
+
return os.path.join(self.settings.output, relpath)
|
|
85
|
+
|
|
86
|
+
def is_date_named(self, file_path: str) -> bool:
|
|
87
|
+
"""Check if a file is named from a date, with an optional duplicate suffix."""
|
|
88
|
+
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
|
89
|
+
for name in (base_name, base_name[:-1]):
|
|
90
|
+
try:
|
|
91
|
+
datetime.strptime(name, self.settings.name_format)
|
|
92
|
+
except ValueError:
|
|
93
|
+
continue
|
|
94
|
+
return name == base_name or base_name[-1] in string.ascii_lowercase
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
def iter_filepaths_from_date(
|
|
98
|
+
self, dest_dir: str, date_taken: datetime, extension: str
|
|
99
|
+
) -> Iterator[str]:
|
|
100
|
+
"""Yield the file paths named from a date, then with a letter suffix."""
|
|
101
|
+
base_name = date_taken.strftime(self.settings.name_format)
|
|
102
|
+
for suffix in ["", *string.ascii_lowercase]:
|
|
103
|
+
yield os.path.join(dest_dir, f"{base_name}{suffix}{extension}")
|
|
104
|
+
|
|
105
|
+
def get_available_filepath_from_date(
|
|
106
|
+
self,
|
|
107
|
+
dest_dir: str,
|
|
108
|
+
date_taken: datetime,
|
|
109
|
+
extension: str,
|
|
110
|
+
source_file: str | None = None,
|
|
111
|
+
) -> str:
|
|
112
|
+
"""Get an available file path named from a date.
|
|
113
|
+
|
|
114
|
+
A letter is appended on name conflicts. The source file path is returned
|
|
115
|
+
when it already has one of the candidate names.
|
|
116
|
+
"""
|
|
117
|
+
for file_path in self.iter_filepaths_from_date(dest_dir, date_taken, extension):
|
|
118
|
+
if source_file is not None and os.path.abspath(
|
|
119
|
+
file_path
|
|
120
|
+
) == os.path.abspath(source_file):
|
|
121
|
+
return file_path
|
|
122
|
+
if not os.path.exists(file_path):
|
|
123
|
+
return file_path
|
|
124
|
+
raise ClassifyException(
|
|
125
|
+
f"No available file name for {date_taken} in {dest_dir}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def find_existing_copy(
|
|
129
|
+
self,
|
|
130
|
+
dest_dir: str,
|
|
131
|
+
date_taken: datetime,
|
|
132
|
+
extension: str,
|
|
133
|
+
is_copy: Callable[[str], bool],
|
|
134
|
+
) -> str | None:
|
|
135
|
+
"""Find a file named from a date that is already a copy of the source."""
|
|
136
|
+
for file_path in self.iter_filepaths_from_date(dest_dir, date_taken, extension):
|
|
137
|
+
if os.path.exists(file_path) and is_copy(file_path):
|
|
138
|
+
return file_path
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def place_in_output(self, path: str, date_taken: datetime, extension: str) -> None:
|
|
142
|
+
"""Copy or move a file to its date name in the output directory."""
|
|
143
|
+
dest_dir_path = self.get_output_path(path)
|
|
144
|
+
if self.settings.keep_original and (
|
|
145
|
+
existing_copy := self.find_existing_copy(
|
|
146
|
+
dest_dir=dest_dir_path,
|
|
147
|
+
date_taken=date_taken,
|
|
148
|
+
extension=extension,
|
|
149
|
+
is_copy=lambda file_path: (
|
|
150
|
+
os.path.abspath(file_path) != os.path.abspath(path)
|
|
151
|
+
and filecmp.cmp(file_path, path, shallow=False)
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
):
|
|
155
|
+
_LOGGER.debug("%s already copied to %s", path, existing_copy)
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
new_path = self.get_available_filepath_from_date(
|
|
159
|
+
dest_dir=dest_dir_path,
|
|
160
|
+
date_taken=date_taken,
|
|
161
|
+
extension=extension,
|
|
162
|
+
source_file=path,
|
|
163
|
+
)
|
|
164
|
+
if os.path.abspath(new_path) == os.path.abspath(path):
|
|
165
|
+
_LOGGER.debug("Already named correctly")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
if self.settings.keep_original:
|
|
169
|
+
_LOGGER.info("Copy %s to %s", path, new_path)
|
|
170
|
+
else:
|
|
171
|
+
_LOGGER.info("Rename %s to %s", path, new_path)
|
|
172
|
+
if self.settings.dry_run:
|
|
173
|
+
return
|
|
174
|
+
os.makedirs(dest_dir_path, exist_ok=True)
|
|
175
|
+
if self.settings.keep_original:
|
|
176
|
+
shutil.copy2(path, new_path)
|
|
177
|
+
else:
|
|
178
|
+
# Unlike os.rename, works when the output is on another file system
|
|
179
|
+
shutil.move(path, new_path)
|
|
180
|
+
|
|
181
|
+
def get_date_from_file_name(self, file_path: str) -> datetime | None:
|
|
182
|
+
"""Get the date from a file name, like PXL_20241010_174118780.mp4."""
|
|
183
|
+
file_name = os.path.basename(file_path)
|
|
184
|
+
for regex, date_format in FILENAME_DATE_FORMATS.items():
|
|
185
|
+
if date_match := re.search(regex, file_name):
|
|
186
|
+
_LOGGER.debug("Date taken from filename: %s", date_match.group(0))
|
|
187
|
+
# Phone file names hold the UTC time
|
|
188
|
+
date_src = datetime.strptime(date_match.group(0), date_format).replace(
|
|
189
|
+
tzinfo=UTC
|
|
190
|
+
)
|
|
191
|
+
return date_src.astimezone(self.settings.user_timezone)
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
def delete_android_trash_files(self) -> None:
|
|
195
|
+
"""Delete Android trash files."""
|
|
196
|
+
for file_path in self.pictures + self.videos:
|
|
197
|
+
file_name = os.path.basename(file_path)
|
|
198
|
+
if file_name.startswith((".trashed", ".pending")):
|
|
199
|
+
_LOGGER.info("Delete %s", file_name)
|
|
200
|
+
self.remove_file(file_path)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Image processor."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from PIL import Image
|
|
8
|
+
from PIL.ExifTags import Base as ExifBase
|
|
9
|
+
|
|
10
|
+
from ..settings import ClassifySettings
|
|
11
|
+
from .files import FileProcessor
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger("classify")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ImageProcessor:
|
|
17
|
+
"""Image processor class"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self, settings: ClassifySettings, file_processor: FileProcessor
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Initialize the class"""
|
|
23
|
+
self.settings = settings
|
|
24
|
+
self.fp = file_processor
|
|
25
|
+
|
|
26
|
+
def get_date_taken(self, path: str) -> datetime | None:
|
|
27
|
+
"""Get the date taken from the exif or the file name of a picture"""
|
|
28
|
+
if date_taken := self.get_exif_date_taken(path):
|
|
29
|
+
return date_taken
|
|
30
|
+
return self.fp.get_date_from_file_name(path)
|
|
31
|
+
|
|
32
|
+
def get_exif_date_taken(self, path: str) -> datetime | None:
|
|
33
|
+
"""Get the date taken from the exif of a picture"""
|
|
34
|
+
with Image.open(path) as img:
|
|
35
|
+
exif = img.getexif()
|
|
36
|
+
if not exif:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
date_taken = None
|
|
40
|
+
|
|
41
|
+
# First check EXIF IFD for DateTimeOriginal
|
|
42
|
+
try:
|
|
43
|
+
exif_ifd = exif.get_ifd(0x8769)
|
|
44
|
+
if int(ExifBase.DateTimeOriginal) in exif_ifd:
|
|
45
|
+
date_taken = exif_ifd[int(ExifBase.DateTimeOriginal)]
|
|
46
|
+
except Exception:
|
|
47
|
+
_LOGGER.debug("Cannot read EXIF IFD of %s", path, exc_info=True)
|
|
48
|
+
|
|
49
|
+
# Fallback to main EXIF for DateTime
|
|
50
|
+
if not date_taken and int(ExifBase.DateTime) in exif:
|
|
51
|
+
date_taken = exif[int(ExifBase.DateTime)]
|
|
52
|
+
|
|
53
|
+
if not date_taken:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
return datetime.strptime(date_taken, "%Y:%m:%d %H:%M:%S")
|
|
58
|
+
except ValueError:
|
|
59
|
+
_LOGGER.warning("Invalid EXIF date %s in %s", date_taken, path)
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def rename_from_date_taken(self, path: str) -> None:
|
|
63
|
+
"""Rename a picture from date taken"""
|
|
64
|
+
picture_date_taken = self.get_date_taken(path)
|
|
65
|
+
if not picture_date_taken:
|
|
66
|
+
_LOGGER.warning("Cannot get date from picture %s", path)
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
_LOGGER.debug(
|
|
70
|
+
"Picture %s taken on %s", os.path.basename(path), picture_date_taken
|
|
71
|
+
)
|
|
72
|
+
extension = os.path.splitext(path)[1].lower()
|
|
73
|
+
if extension == ".jpeg":
|
|
74
|
+
extension = ".jpg"
|
|
75
|
+
self.fp.place_in_output(path, picture_date_taken, extension)
|
|
76
|
+
|
|
77
|
+
def process(self, path: str) -> None:
|
|
78
|
+
"""Process a picture"""
|
|
79
|
+
self.rename_from_date_taken(path)
|