Skip to content

Commit a39ffb0

Browse files
authored
Add fix.exif_data (#7)
- Removed fix.v1 - py exif doesn't support Apple Photos right so uses exiftool as output. - Need to integrate exif_data with the indexer - Moved code into common
1 parent 512d6d5 commit a39ffb0

18 files changed

+519
-296
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ Documentation: https://rnpix.readthedocs.io
1010

1111
License: https://www.apache.org/licenses/LICENSE-2.0.html
1212

13-
Copyright (c) 2024 Rob Nagler. All Rights Reserved.
13+
Copyright (c) 2024 Robert Nagler. All Rights Reserved.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
authors = [
7-
{ name = "Rob Nagler", email = "[email protected]" },
7+
{ name = "Robert Nagler", email = "[email protected]" },
88
]
99
classifiers = [
1010
"Development Status :: 2 - Pre-Alpha",
@@ -17,6 +17,7 @@ classifiers = [
1717
]
1818
dependencies = [
1919
"exif",
20+
"pillow",
2021
"pykern",
2122
]
2223
description = "Photo library tools"

rnpix/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
""":mod:`rnpix` package
22
3-
:copyright: Copyright (c) 2024 Rob Nagler. All Rights Reserved.
3+
:copyright: Copyright (c) 2024 Robert Nagler. All Rights Reserved.
44
:license: https://www.apache.org/licenses/LICENSE-2.0.html
55
"""
6+
67
import pkg_resources
78

89
try:

rnpix/common.py

Lines changed: 202 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
# -*- coding: utf-8 -*-
21
"""Common code
32
4-
:copyright: Copyright (c) 2017 Robert Nagler. All Rights Reserved.
3+
:copyright: Copyright (c) 2017-2025 Robert Nagler. All Rights Reserved.
54
:license: http://www.apache.org/licenses/LICENSE-2.0.html
65
"""
7-
from __future__ import absolute_import, division, print_function
8-
from pykern.pkdebug import pkdlog, pkdp
6+
7+
from pykern.pkcollections import PKDict
8+
from pykern.pkdebug import pkdc, pkdlog, pkdp
99
import contextlib
1010
import datetime
1111
import errno
12+
import exif
1213
import os
1314
import os.path
1415
import pykern.pkio
@@ -24,7 +25,7 @@
2425

2526
_STILL = "jpg|heic|png|tif|gif|psd|pdf|thm|jpeg"
2627

27-
STILL = re.compile(
28+
KNOWN_EXT = re.compile(
2829
r"^(.+)\.({}|{}|{})$".format(_STILL, _MOVIES, _NEED_JPG),
2930
flags=re.IGNORECASE,
3031
)
@@ -41,95 +42,176 @@
4142

4243
THUMB_DIR = re.compile("^(?:200|50)$")
4344

45+
INDEX_LINE = re.compile(r"^([^\s:]+)\s*(.*)")
4446

45-
@contextlib.contextmanager
46-
def user_lock():
47-
# Lock directories don't work within Dropbox folders, because
48-
# Dropbox uploads them and they can hang around after deleting here.
49-
lock_d = "/tmp/rnpix-lock-" + os.environ["USER"]
50-
lock_pid = os.path.join(lock_d, "pid")
47+
MISSING_DESC = "?"
5148

52-
def _pid():
53-
res = -1
54-
try:
55-
with open(lock_pid) as f:
56-
res = int(f.read())
57-
except Exception:
58-
pass
59-
pkdlog(res)
60-
if res <= 0:
61-
return res
62-
try:
63-
os.kill(res, 0)
64-
except Exception as e:
65-
pkdlog(e)
66-
if isinstance(e, OSError) and e.errno == errno.ESRCH:
67-
return res
68-
return -1
49+
# Creation Date Value is 2021:03:15 07:10:01-06:00
50+
# it's not a date, just a string but it has timezone
51+
DATE_TIME_RE = re.compile(r"((?:18|19|20)\d\d)\D(\d\d)\D(\d\d)\D(\d\d)\D(\d\d)\D(\d\d)")
6952

70-
is_locked = False
53+
# Also includes a trailing diit possibly
54+
DATE_RE = re.compile(r"((?:18|19|20)\d\d)\D?(\d\d)\D?(\d\d)\D+(\d*)")
55+
56+
BASE_FTIME = "%Y-%m-%d-%H.%M.%S"
57+
BASE_FMT = "{}-{}-{}-{}.{}.{}"
58+
DIR_FMT = "{}/{}-{}"
59+
DIR_FTIME = "%Y/%m-%d"
60+
61+
ORIGINAL_FTIME = "%Y:%m:%d %H:%M:%S"
62+
63+
64+
def date_time_parse(path):
65+
if m := DATE_TIME_RE.search(path.purebasename):
66+
d = m.groups()
67+
elif (m := DATE_RE.search(path.purebasename)) or (m := DATE_RE.search(str(path))):
68+
d = [m.group(1), m.group(2), m.group(3), 12]
69+
s = int(m.group(4) or 0)
70+
d.extend((s // 60, s % 60))
71+
else:
72+
return None
73+
return datetime.datetime(*list(map(int, d)))
74+
75+
76+
def exif_image(readable):
77+
if isinstance(readable, exif.Image):
78+
return readable
79+
# Handle py.path
80+
if a := getattr(readable, "open", None):
81+
readable = a("rb")
82+
return exif.Image(readable)
83+
84+
85+
def exif_parse(readable):
86+
def _date_time(exif_image, date_time):
87+
if date_time is None:
88+
return None
89+
if z := getattr(exif_image, "offset_time_original", None):
90+
return (
91+
datetime.datetime.strptime(date_time + z, ORIGINAL_FTIME + "%z")
92+
.astimezone(datetime.timezone.utc)
93+
.replace(tzinfo=None)
94+
)
95+
return datetime.datetime.strptime(date_time, ORIGINAL_FTIME)
96+
97+
i = exif_image(readable)
7198
try:
72-
for i in range(5):
73-
try:
74-
os.mkdir(lock_d)
75-
is_locked = True
76-
with open(lock_pid, "w") as f:
77-
f.write(str(os.getpid()))
78-
break
79-
except OSError as e:
80-
if e.errno != errno.EEXIST:
81-
raise
82-
pid = _pid()
83-
if pid <= 0:
84-
time.sleep(0.4)
85-
continue
86-
if pid == _pid():
87-
os.remove(lock_pid)
88-
os.rmdir(lock_d)
99+
t = getattr(i, "datetime_original", None)
100+
d = getattr(i, "image_description", None)
101+
except KeyError:
102+
# I guess if there's no metadata, it gets this
103+
# File "exif/_image.py", line 104, in __getattr__
104+
# KeyError: 'APP1'
105+
t = d = None
106+
return PKDict(date_time=_date_time(i, t), description=d)
107+
108+
109+
def exif_set(readable, path=None, date_time=None, description=None):
110+
if path is None:
111+
path = readable
112+
assert path.ext == ".jpg"
113+
assert date_time or description
114+
e = exif_image(readable)
115+
if date_time is not None:
116+
e.datetime_original = date_time.strftime(ORIGINAL_FTIME)
117+
if description is not None:
118+
e.image_description = description
119+
path.write(e.get_file(), "wb")
120+
return date_time
121+
122+
123+
def index_parse(path=None):
124+
def _parse(line):
125+
nonlocal path
126+
if not (i := _split(line)):
127+
pass
128+
elif not path.new(basename=i.name).exists():
129+
pkdlog("indexed image={} does not exist", i.name)
130+
elif i.name in rv:
131+
pkdlog(
132+
"duplicate image={} in {}; skipping desc={}",
133+
i.name,
134+
path,
135+
i.desc,
136+
)
137+
elif not KNOWN_EXT.search(i.name):
138+
pkdlog(
139+
"invalid ext image={} in {}; skipping desc={}",
140+
i.name,
141+
path,
142+
i.desc,
143+
)
144+
elif i.desc == MISSING_DESC:
145+
# assume everything will get identified
146+
pass
89147
else:
90-
raise ValueError("{}: unable to create lock".format(lock_d))
91-
yield lock_d
92-
finally:
93-
if is_locked:
94-
os.remove(lock_pid)
95-
os.rmdir(lock_d)
148+
# success
149+
return i
150+
return None
151+
152+
def _split(line):
153+
l = line.rstrip()
154+
if l and not l.startswith("#"):
155+
if m := INDEX_LINE.search(l):
156+
return PKDict(zip(("name", "desc"), m.groups()))
157+
pkdlog("invalid line={}", l)
158+
return None
159+
160+
rv = PKDict()
161+
if path is None:
162+
path = pykern.pkio.py_path()
163+
if path.check(dir=1):
164+
path = path.join("index.txt")
165+
if not path.exists():
166+
# No index so return empty PKDict so can be added to
167+
return rv
168+
with path.open("rt") as f:
169+
for l in f:
170+
if i := _parse(l):
171+
rv[i.name] = i.desc
172+
return rv
173+
174+
175+
def index_update(image, desc):
176+
i = index_parse()
177+
i[image] = desc
178+
index_write(i)
179+
180+
181+
def index_write(values):
182+
with open("index.txt", "w") as f:
183+
f.write("".join(k + " " + v + "\n" for k, v in values.items()))
96184

97185

98186
def move_one(src, dst_root=None):
99187
e = src.ext.lower()
100188
if e == ".jpeg":
101189
e = ".jpg"
102-
f1 = "%Y-%m-%d-%H.%M.%S"
103-
f2 = "{}-{}-{}-{}.{}.{}"
104190
# CreationDate is in timezone as is DateTimeOriginal but not for movies
105191
z = (
106-
("-CreationDate", "-CreationDateValue", "-createdate")
192+
("-CreationDate", "-CreationDateValue", "-createdate", "-DateTimeOriginal")
107193
if MOVIE.search(src.basename)
108194
else ("-DateTimeOriginal",)
109195
)
110196
d = None
111197
for y in z:
112198
p = subprocess.run(
113-
("exiftool", "-d", f1, y, "-S", "-s", src),
199+
("exiftool", "-d", BASE_FTIME, y, "-S", "-s", src),
114200
stdout=subprocess.PIPE,
115201
stderr=subprocess.PIPE,
116202
universal_newlines=True,
117203
)
118204
if p.returncode != 0:
119-
pykern.pkcli.command_error("exiftool failed: {} {}".format(src, p.stderr))
120-
m = re.search(
121-
r"((?:20|19)\d\d)\D(\d\d)\D(\d\d)\D(\d\d)\D(\d\d)\D(\d\d)", str(p.stdout)
122-
)
123-
if m:
124-
# Creation Date Value is 2021:03:15 07:10:01-06:00
125-
# it's not a date, just a string but it has timezone
126-
t = f2.format(*m.groups())
127-
d = "{}/{}-{}".format(*m.groups())
205+
pkdlog("exiftool failed: path={} stderr={}", src, p.stderr)
206+
raise RuntimeError(f"unable to parse image={src}")
207+
if m := DATE_TIME_RE.search(str(p.stdout)):
208+
t = BASE_FMT.format(*m.groups())
209+
d = DIR_FMT.format(*m.groups())
128210
break
129211
if not d:
130212
d = datetime.datetime.fromtimestamp(src.mtime())
131-
t = d.strftime(f1)
132-
d = d.strftime("%Y/%m-%d")
213+
t = d.strftime(BASE_FTIME)
214+
d = d.strftime(DIR_FTIME)
133215
pkdlog("use mtime: {} => {}", src, t)
134216
if dst_root:
135217
d = dst_root.join(d)
@@ -166,6 +248,59 @@ def root():
166248
return pykern.pkio.py_path(r)
167249

168250

251+
@contextlib.contextmanager
252+
def user_lock():
253+
# Lock directories don't work within Dropbox folders, because
254+
# Dropbox uploads them and they can hang around after deleting here.
255+
lock_d = "/tmp/rnpix-lock-" + os.environ["USER"]
256+
lock_pid = os.path.join(lock_d, "pid")
257+
258+
def _pid():
259+
res = -1
260+
try:
261+
with open(lock_pid) as f:
262+
res = int(f.read())
263+
except Exception:
264+
pass
265+
pkdlog(res)
266+
if res <= 0:
267+
return res
268+
try:
269+
os.kill(res, 0)
270+
except Exception as e:
271+
pkdlog(e)
272+
if isinstance(e, OSError) and e.errno == errno.ESRCH:
273+
return res
274+
return -1
275+
276+
is_locked = False
277+
try:
278+
for i in range(5):
279+
try:
280+
os.mkdir(lock_d)
281+
is_locked = True
282+
with open(lock_pid, "w") as f:
283+
f.write(str(os.getpid()))
284+
break
285+
except OSError as e:
286+
if e.errno != errno.EEXIST:
287+
raise
288+
pid = _pid()
289+
if pid <= 0:
290+
time.sleep(0.4)
291+
continue
292+
if pid == _pid():
293+
os.remove(lock_pid)
294+
os.rmdir(lock_d)
295+
else:
296+
raise ValueError("{}: unable to create lock".format(lock_d))
297+
yield lock_d
298+
finally:
299+
if is_locked:
300+
os.remove(lock_pid)
301+
os.rmdir(lock_d)
302+
303+
169304
def _fix_index(d, old, new):
170305
i = d.join("index.txt")
171306
if not i.exists():

rnpix/package_data/static/ext/underscore-1.13.1-umd-min.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)