photo26/photologue/utils.py
krek0 f7415fb5f6
All checks were successful
Docker / build (release) Successful in 9s
Sort gallery media by capture date instead of file name
2026-09-24 12:52:50 +02:00

154 lines
5.5 KiB
Python

# This file is part of photo21
# Copyright (C) 2022 Amicale des élèves de l'ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
import os
from datetime import datetime, timezone as dt_timezone
from django.conf import settings
from django.core.files.base import ContentFile
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from io import BytesIO
from PIL import Image
import av
logger = logging.getLogger("photologue.utils")
def is_photo(file_obj):
"""Return True if file_obj is a valid image readable by Pillow."""
try:
Image.open(file_obj).verify()
file_obj.seek(0)
return True
except Exception:
file_obj.seek(0)
return False
def is_video(file_obj):
"""Return True if file_obj is a valid video container (has at least one video stream)."""
try:
container = av.open(file_obj)
ok = len(container.streams.video) > 0
container.close()
file_obj.seek(0)
return ok
except Exception:
file_obj.seek(0)
return False
def generate_video_thumbnail(video):
"""Extract the first frame of a video file and save it as the video thumbnail."""
try:
container = av.open(video.file.path)
frame = next(container.decode(video=0))
img = frame.to_image()
rotation = frame.rotation
container.close()
if rotation:
img = img.rotate(rotation, expand=True)
except Exception:
logger.error("Failed to extract video frame for thumbnail", exc_info=True)
return
try:
buffer = BytesIO()
img.save(buffer, "JPEG", quality=70, optimize=True)
# Preserve directory structure: strip the "videos/" storage prefix so that
# get_video_storage_path places the thumb alongside the video file.
rel = video.file.name[len("videos/"):] if video.file.name.startswith("videos/") else video.file.name
thumb_name = os.path.splitext(rel)[0] + "_thumb.jpg"
video.thumbnail.save(thumb_name, ContentFile(buffer.getvalue()), save=True)
except Exception:
logger.error("Failed to save video thumbnail", exc_info=True)
def capture_date_from_exif(exif_value):
"""Turn an EXIF "YYYY:MM:DD HH:MM:SS" value into an aware datetime.
EXIF dates carry no time zone: they are local time as read by the camera, so
they are interpreted in the site time zone.
"""
try:
day, hour = str(exif_value).split()
year, month, dayofmonth = (int(part) for part in day.split(":"))
hours, minutes, seconds = (int(part) for part in hour.split(":"))
naive = datetime(year, month, dayofmonth, hours, minutes, seconds)
except (AttributeError, TypeError, ValueError):
logger.error("Unreadable EXIF date %r", exif_value)
return None
return timezone.make_aware(naive) if settings.USE_TZ else naive
def capture_date_from_video(path):
"""Read the capture date from a video container, or None."""
try:
container = av.open(path)
raw = container.metadata.get("creation_time")
container.close()
except Exception:
logger.error("Failed to read video metadata from %s", path, exc_info=True)
return None
if not raw:
return None
parsed = parse_datetime(raw)
if parsed is None:
return None
if settings.USE_TZ and timezone.is_naive(parsed):
# Containers usually store UTC; a naive value is the camera's local time.
parsed = timezone.make_aware(parsed)
return parsed
def capture_date_from_upload(last_modified_ms):
"""Turn a browser File.lastModified value (ms since epoch) into a datetime."""
try:
stamp = int(last_modified_ms) / 1000
except (TypeError, ValueError):
return None
if not stamp:
return None
return datetime.fromtimestamp(stamp, tz=dt_timezone.utc)
def order_media_by_capture(items):
"""Sort media in capture order, and fill in the dates that are missing.
A file name is a reliable chronological clue inside one photographer's batch
(most cameras number their files in order) and meaningless between two of
them. So media without a capture date borrow the date of the closest dated
file of the same owner, which puts them back in the middle of the sequence
instead of exiling them at the end of the gallery. Only media whose owner has
no dated file at all end up at the end, in file name order.
Every item is annotated with `capture_date` and `capture_date_is_guessed`.
"""
by_owner = {}
for item in items:
item.capture_date = item.date_taken
item.capture_date_is_guessed = False
by_owner.setdefault(item.owner_id, []).append(item)
dated, undated = [], []
for batch in by_owner.values():
batch.sort(key=lambda item: (item.title, item.pk))
# Forwards, then backwards for the files that come before the first date.
for sequence in (batch, batch[::-1]):
known = None
for item in sequence:
if item.capture_date is not None:
known = item.capture_date
elif known is not None:
item.capture_date = known
item.capture_date_is_guessed = True
for item in batch:
(dated if item.capture_date is not None else undated).append(item)
dated.sort(key=lambda item: (item.capture_date, item.owner_id, item.title, item.pk))
undated.sort(key=lambda item: (item.owner_id, item.title, item.pk))
return dated + undated