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 ADDED
@@ -0,0 +1 @@
1
+ """Init python."""
classify/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Main python."""
2
+
3
+ from .main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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
@@ -0,0 +1,9 @@
1
+ """Exceptions for the package."""
2
+
3
+
4
+ class ClassifyException(Exception):
5
+ """Classify exception."""
6
+
7
+
8
+ class ClassifyEncodingException(Exception):
9
+ """Exception raised when encoding fails."""
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)