Sort gallery media by capture date instead of file name
All checks were successful
Docker / build (release) Successful in 9s
All checks were successful
Docker / build (release) Successful in 9s
This commit is contained in:
parent
2f0c426dc1
commit
f7415fb5f6
11 changed files with 314 additions and 33 deletions
|
|
@ -209,7 +209,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
||||||
|
|
||||||
TIME_ZONE = "UTC"
|
TIME_ZONE = "Europe/Paris"
|
||||||
|
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,14 +45,20 @@ class MediaAdmin(admin.ModelAdmin):
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
return super().get_queryset(request).prefetch_related("owner", "galleries")
|
return super().get_queryset(request).prefetch_related("owner", "galleries")
|
||||||
|
|
||||||
|
def get_date_source(self, obj):
|
||||||
|
return obj.get_date_source_display() or "-"
|
||||||
|
|
||||||
get_owner.admin_order_field = "owner"
|
get_owner.admin_order_field = "owner"
|
||||||
get_owner.short_description = _("owner")
|
get_owner.short_description = _("owner")
|
||||||
get_galleries.short_description = _("Gallery")
|
get_galleries.short_description = _("Gallery")
|
||||||
|
get_date_source.admin_order_field = "date_source"
|
||||||
|
get_date_source.short_description = _("date source")
|
||||||
|
|
||||||
|
|
||||||
class PhotoAdmin(MediaAdmin):
|
class PhotoAdmin(MediaAdmin):
|
||||||
list_display = ("title", "date_taken", "date_added", "is_public", "view_count", "admin_thumbnail", "get_owner", "get_galleries")
|
list_display = ("title", "date_taken", "get_date_source", "date_added", "is_public", "view_count", "admin_thumbnail", "get_owner", "get_galleries")
|
||||||
readonly_fields = ("date_taken",)
|
# The capture date is editable so that a camera set to the wrong time can be
|
||||||
|
# corrected; an edited date is then marked as manual and never overwritten.
|
||||||
model = Photo
|
model = Photo
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -64,7 +70,7 @@ class TagAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
|
|
||||||
class VideoAdmin(MediaAdmin):
|
class VideoAdmin(MediaAdmin):
|
||||||
list_display = ("title", "date_added", "is_public", "get_owner", "get_galleries")
|
list_display = ("title", "date_taken", "get_date_source", "date_added", "is_public", "get_owner", "get_galleries")
|
||||||
model = Video
|
model = Video
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ class MultipleFileField(forms.FileField):
|
||||||
|
|
||||||
class UploadForm(forms.Form):
|
class UploadForm(forms.Form):
|
||||||
file_field = MultipleFileField(label="")
|
file_field = MultipleFileField(label="")
|
||||||
|
# File.lastModified, in milliseconds, sent by upload.js. An upload is the last
|
||||||
|
# chance to catch it: the date of the file itself does not survive HTTP.
|
||||||
|
file_modified = forms.IntegerField(required=False, widget=forms.HiddenInput)
|
||||||
gallery = forms.ModelChoiceField(
|
gallery = forms.ModelChoiceField(
|
||||||
Gallery.objects.all(),
|
Gallery.objects.all(),
|
||||||
label=_("Gallery"),
|
label=_("Gallery"),
|
||||||
|
|
|
||||||
77
photologue/management/commands/backfill_capture_dates.py
Normal file
77
photologue/management/commands/backfill_capture_dates.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from photologue.models import DATE_FROM_EXIF, DATE_FROM_VIDEO, DATE_MANUAL, Photo, Video
|
||||||
|
from photologue.utils import capture_date_from_exif, capture_date_from_video
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"Read the capture date of existing media from their metadata. Media whose "
|
||||||
|
"date was set by hand are never touched. By default only media without a "
|
||||||
|
"date, and media imported before dates were tracked, are read."
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="Re-read every media, even those with a known date source.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Report what would change without writing anything.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
for model, reader in ((Photo, self.photo_date), (Video, self.video_date)):
|
||||||
|
queryset = model.objects.exclude(date_source=DATE_MANUAL)
|
||||||
|
if not options["force"]:
|
||||||
|
# An empty source means the media predates this field, and its date
|
||||||
|
# may have been stored in the wrong time zone.
|
||||||
|
queryset = queryset.filter(date_taken__isnull=True) | queryset.filter(date_source="")
|
||||||
|
self.process(model, queryset.distinct(), reader, options)
|
||||||
|
|
||||||
|
def process(self, model, queryset, reader, options):
|
||||||
|
name = model._meta.verbose_name_plural
|
||||||
|
found = unchanged = missing = 0
|
||||||
|
for media in queryset.iterator():
|
||||||
|
taken, source = reader(media)
|
||||||
|
if taken is None:
|
||||||
|
missing += 1
|
||||||
|
self.stdout.write(f" no date in {media.title}")
|
||||||
|
continue
|
||||||
|
if taken == media.date_taken and source == media.date_source:
|
||||||
|
unchanged += 1
|
||||||
|
continue
|
||||||
|
found += 1
|
||||||
|
self.stdout.write(f" {media.title}: {media.date_taken} -> {taken} ({source})")
|
||||||
|
if not options["dry_run"]:
|
||||||
|
media.set_capture_date(taken, source)
|
||||||
|
media.save(update_fields=["date_taken", "date_source"])
|
||||||
|
done = "would be updated" if options["dry_run"] else "updated"
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"{name}: {found} {done}, {unchanged} already correct, {missing} without metadata")
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def photo_date(photo):
|
||||||
|
if not photo.image:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
exif_date = photo.exif(photo.image.file).get("EXIF DateTimeOriginal", None)
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
if exif_date is None:
|
||||||
|
return None, None
|
||||||
|
return capture_date_from_exif(exif_date.values), DATE_FROM_EXIF
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def video_date(video):
|
||||||
|
if not video.file:
|
||||||
|
return None, None
|
||||||
|
return capture_date_from_video(video.file.path), DATE_FROM_VIDEO
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Generated by Django 6.0.4 on 2026-09-24 09:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('photologue', '0010_video'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='photo',
|
||||||
|
name='date_source',
|
||||||
|
field=models.CharField(blank=True, choices=[('exif', 'EXIF metadata'), ('video', 'video metadata'), ('file', 'file date at upload'), ('manual', 'set by hand')], editable=False, help_text='Where the capture date comes from.', max_length=10, verbose_name='date source'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='video',
|
||||||
|
name='date_source',
|
||||||
|
field=models.CharField(blank=True, choices=[('exif', 'EXIF metadata'), ('video', 'video metadata'), ('file', 'file date at upload'), ('manual', 'set by hand')], editable=False, help_text='Where the capture date comes from.', max_length=10, verbose_name='date source'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='video',
|
||||||
|
name='date_taken',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Date the media was captured; read from its metadata.', null=True, verbose_name='date taken'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='photo',
|
||||||
|
name='date_taken',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Date the media was captured; read from its metadata.', null=True, verbose_name='date taken'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from datetime import datetime
|
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from inspect import isclass
|
from inspect import isclass
|
||||||
|
|
@ -39,6 +38,19 @@ LATEST_LIMIT = getattr(settings, "PHOTOLOGUE_GALLERY_LATEST_LIMIT", None)
|
||||||
# max_length setting for the ImageModel ImageField
|
# max_length setting for the ImageModel ImageField
|
||||||
IMAGE_FIELD_MAX_LENGTH = getattr(settings, "PHOTOLOGUE_IMAGE_FIELD_MAX_LENGTH", 100)
|
IMAGE_FIELD_MAX_LENGTH = getattr(settings, "PHOTOLOGUE_IMAGE_FIELD_MAX_LENGTH", 100)
|
||||||
|
|
||||||
|
# Where a capture date comes from, from the most to the least trustworthy. An
|
||||||
|
# empty value means the date was never determined.
|
||||||
|
DATE_FROM_EXIF = "exif"
|
||||||
|
DATE_FROM_VIDEO = "video"
|
||||||
|
DATE_FROM_FILE = "file"
|
||||||
|
DATE_MANUAL = "manual"
|
||||||
|
DATE_SOURCE_CHOICES = (
|
||||||
|
(DATE_FROM_EXIF, _("EXIF metadata")),
|
||||||
|
(DATE_FROM_VIDEO, _("video metadata")),
|
||||||
|
(DATE_FROM_FILE, _("file date at upload")),
|
||||||
|
(DATE_MANUAL, _("set by hand")),
|
||||||
|
)
|
||||||
|
|
||||||
# Modify image file buffer size.
|
# Modify image file buffer size.
|
||||||
ImageFile.MAXBLOCK = getattr(settings, "PHOTOLOGUE_MAXBLOCK", 256 * 2**10)
|
ImageFile.MAXBLOCK = getattr(settings, "PHOTOLOGUE_MAXBLOCK", 256 * 2**10)
|
||||||
|
|
||||||
|
|
@ -265,6 +277,20 @@ class MediaModel(models.Model):
|
||||||
)
|
)
|
||||||
thumbnail_width = models.PositiveIntegerField(editable=False, null=True)
|
thumbnail_width = models.PositiveIntegerField(editable=False, null=True)
|
||||||
thumbnail_height = models.PositiveIntegerField(editable=False, null=True)
|
thumbnail_height = models.PositiveIntegerField(editable=False, null=True)
|
||||||
|
date_taken = models.DateTimeField(
|
||||||
|
_("date taken"),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=_("Date the media was captured; read from its metadata."),
|
||||||
|
)
|
||||||
|
date_source = models.CharField(
|
||||||
|
_("date source"),
|
||||||
|
max_length=10,
|
||||||
|
blank=True,
|
||||||
|
editable=False,
|
||||||
|
choices=DATE_SOURCE_CHOICES,
|
||||||
|
help_text=_("Where the capture date comes from."),
|
||||||
|
)
|
||||||
|
|
||||||
is_video = False
|
is_video = False
|
||||||
|
|
||||||
|
|
@ -288,9 +314,32 @@ class MediaModel(models.Model):
|
||||||
def get_download_url(self):
|
def get_download_url(self):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def date_is_approximate(self):
|
||||||
|
"""True when the date was deduced rather than read from the capture itself."""
|
||||||
|
return self.date_source == DATE_FROM_FILE
|
||||||
|
|
||||||
|
def set_capture_date(self, value, source):
|
||||||
|
"""Record a capture date read from the file, and where it comes from."""
|
||||||
|
self.date_taken = value
|
||||||
|
self.date_source = source
|
||||||
|
self._date_read_from_file = True
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
self.slug = slugify(self.title)
|
self.slug = slugify(self.title)
|
||||||
|
if self.pk and not getattr(self, "_date_read_from_file", False):
|
||||||
|
# A date changed by anything but our own readers was set by a human,
|
||||||
|
# and must survive a later re-read of the metadata.
|
||||||
|
stored = (
|
||||||
|
type(self)
|
||||||
|
.objects.filter(pk=self.pk)
|
||||||
|
.values_list("date_taken", flat=True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if stored != self.date_taken:
|
||||||
|
self.date_source = DATE_MANUAL
|
||||||
|
self._date_read_from_file = False
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -302,12 +351,6 @@ class Photo(MediaModel):
|
||||||
width_field="thumbnail_width",
|
width_field="thumbnail_width",
|
||||||
height_field="thumbnail_height",
|
height_field="thumbnail_height",
|
||||||
)
|
)
|
||||||
date_taken = models.DateTimeField(
|
|
||||||
_("date taken"),
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
help_text=_("Date image was taken; is obtained from the image EXIF data."),
|
|
||||||
)
|
|
||||||
view_count = models.PositiveIntegerField(_("view count"), default=0, editable=False)
|
view_count = models.PositiveIntegerField(_("view count"), default=0, editable=False)
|
||||||
crop_from = models.CharField(
|
crop_from = models.CharField(
|
||||||
_("crop from"),
|
_("crop from"),
|
||||||
|
|
@ -577,21 +620,15 @@ class Photo(MediaModel):
|
||||||
if (self.date_taken is None or image_has_changed) and self.image:
|
if (self.date_taken is None or image_has_changed) and self.image:
|
||||||
# Attempt to get the date the photo was taken from the EXIF data.
|
# Attempt to get the date the photo was taken from the EXIF data.
|
||||||
try:
|
try:
|
||||||
|
from .utils import capture_date_from_exif
|
||||||
|
|
||||||
exif_date = self.exif(self.image.file).get(
|
exif_date = self.exif(self.image.file).get(
|
||||||
"EXIF DateTimeOriginal", None
|
"EXIF DateTimeOriginal", None
|
||||||
)
|
)
|
||||||
if exif_date is not None:
|
if exif_date is not None:
|
||||||
d, t = exif_date.values.split()
|
taken = capture_date_from_exif(exif_date.values)
|
||||||
year, month, day = d.split(":")
|
if taken is not None:
|
||||||
hour, minute, second = t.split(":")
|
self.set_capture_date(taken, DATE_FROM_EXIF)
|
||||||
self.date_taken = datetime(
|
|
||||||
int(year),
|
|
||||||
int(month),
|
|
||||||
int(day),
|
|
||||||
int(hour),
|
|
||||||
int(minute),
|
|
||||||
int(second),
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.error("Failed to read EXIF DateTimeOriginal", exc_info=True)
|
logger.error("Failed to read EXIF DateTimeOriginal", exc_info=True)
|
||||||
# If crop_from property has been changed on existing image,
|
# If crop_from property has been changed on existing image,
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,8 @@ async function uploadFile(file, galleryID, csrfvalue, ownerID) {
|
||||||
const sendform = new FormData();
|
const sendform = new FormData();
|
||||||
sendform.append('csrfmiddlewaretoken', csrfvalue);
|
sendform.append('csrfmiddlewaretoken', csrfvalue);
|
||||||
sendform.append('file_field', file);
|
sendform.append('file_field', file);
|
||||||
|
// The capture date is often missing from the metadata but present here.
|
||||||
|
if (file.lastModified) sendform.append('file_modified', file.lastModified);
|
||||||
sendform.append('gallery', galleryID);
|
sendform.append('gallery', galleryID);
|
||||||
if (ownerID) sendform.append('owner', ownerID);
|
if (ownerID) sendform.append('owner', ownerID);
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
data-height="{{ item.thumbnail_height|default:1 }}">
|
data-height="{{ item.thumbnail_height|default:1 }}">
|
||||||
<img src="{{ item.get_thumbnail_url }}" data-lazy="{{ item.get_thumbnail_url }}"
|
<img src="{{ item.get_thumbnail_url }}" data-lazy="{{ item.get_thumbnail_url }}"
|
||||||
class="{% if not item.is_public %}photo-private{% endif %}"
|
class="{% if not item.is_public %}photo-private{% endif %}"
|
||||||
alt="{{ item.title }}{% if not item.is_video and item.date_taken %} - {{ item.date_taken|date }} {{ item.date_taken|time }}{% endif %} - {{ item.owner.first_name|default:item.owner.username }}{% if not item.is_video and item.license %} - {{ item.license }}{% endif %}{% if not item.is_public %} - !PRIVATE!{% endif %}">
|
alt="{{ item.title }}{% if item.date_taken %} - {{ item.date_taken|date }} {{ item.date_taken|time }}{% endif %} - {{ item.owner.first_name|default:item.owner.username }}{% if not item.is_video and item.license %} - {{ item.license }}{% endif %}{% if not item.is_public %} - !PRIVATE!{% endif %}">
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-12">
|
<div class="col-lg-12">
|
||||||
<h1>{{ object.title }}</h1>
|
<h1>{{ object.title }}</h1>
|
||||||
<p class="text-muted small">{% trans "Published" %} {{ object.date_added }}</p>
|
<p class="text-muted small">
|
||||||
|
{% if object.date_taken %}{% trans "Taken" %} {{ object.date_taken }}{% if object.date_is_approximate %} ({% trans "approximate date" %}){% endif %} · {% endif %}
|
||||||
|
{% trans "Published" %} {{ object.date_added }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if object.caption %}<p>{{ object.caption }}</p>{% endif %}
|
{% if object.caption %}<p>{{ object.caption }}</p>{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,12 @@
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from datetime import datetime, timezone as dt_timezone
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.dateparse import parse_datetime
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import av
|
import av
|
||||||
|
|
@ -61,3 +66,89 @@ def generate_video_thumbnail(video):
|
||||||
video.thumbnail.save(thumb_name, ContentFile(buffer.getvalue()), save=True)
|
video.thumbnail.save(thumb_name, ContentFile(buffer.getvalue()), save=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.error("Failed to save video thumbnail", exc_info=True)
|
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
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,15 @@ from django.contrib.auth import get_user_model
|
||||||
from django.db.models import F, Q
|
from django.db.models import F, Q
|
||||||
|
|
||||||
from .forms import UploadForm
|
from .forms import UploadForm
|
||||||
from .models import Gallery, Photo, Tag, Video
|
from .models import DATE_FROM_FILE, DATE_FROM_VIDEO, Gallery, Photo, Tag, Video
|
||||||
from .utils import generate_video_thumbnail, is_photo, is_video
|
from .utils import (
|
||||||
|
capture_date_from_upload,
|
||||||
|
capture_date_from_video,
|
||||||
|
generate_video_thumbnail,
|
||||||
|
is_photo,
|
||||||
|
is_video,
|
||||||
|
order_media_by_capture,
|
||||||
|
)
|
||||||
|
|
||||||
MEDIA_MODELS = {"photo": Photo, "video": Video}
|
MEDIA_MODELS = {"photo": Photo, "video": Video}
|
||||||
|
|
||||||
|
|
@ -229,9 +236,9 @@ class GalleryDetailView(DetailView):
|
||||||
context["photos"] = context["photos"].filter(owner__id=self.kwargs["owner"])
|
context["photos"] = context["photos"].filter(owner__id=self.kwargs["owner"])
|
||||||
context["videos"] = context["videos"].filter(owner__id=self.kwargs["owner"])
|
context["videos"] = context["videos"].filter(owner__id=self.kwargs["owner"])
|
||||||
|
|
||||||
# Combine photos and videos into a single sorted list for the template
|
# Combine photos and videos into a single list, in capture order
|
||||||
context["media_items"] = sorted(
|
context["media_items"] = order_media_by_capture(
|
||||||
[*context["photos"], *context["videos"]], key=lambda x: x.title
|
[*context["photos"], *context["videos"]]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Increment the photo view count
|
# Increment the photo view count
|
||||||
|
|
@ -304,7 +311,25 @@ class GalleryUpload(PermissionRequiredMixin, FormView):
|
||||||
kwargs["is_staff"] = self.request.user.is_staff
|
kwargs["is_staff"] = self.request.user.is_staff
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def _upload_media(self, model, file_field, file_obj, gallery, gallery_dir, owner, post_save=None):
|
@staticmethod
|
||||||
|
def _complete_capture_date(obj, modified_ms):
|
||||||
|
"""
|
||||||
|
Fill in the capture date that the file itself did not carry: photos get
|
||||||
|
theirs from EXIF when saved, videos from their container, and anything
|
||||||
|
still undated falls back on the file date sent by the browser.
|
||||||
|
"""
|
||||||
|
taken = source = None
|
||||||
|
if obj.date_taken is None and obj.is_video:
|
||||||
|
taken, source = capture_date_from_video(obj.file_path), DATE_FROM_VIDEO
|
||||||
|
if taken is None and obj.date_taken is None and modified_ms:
|
||||||
|
taken, source = capture_date_from_upload(modified_ms), DATE_FROM_FILE
|
||||||
|
if taken is None:
|
||||||
|
return
|
||||||
|
obj.set_capture_date(taken, source)
|
||||||
|
obj.save(update_fields=["date_taken", "date_source"])
|
||||||
|
|
||||||
|
def _upload_media(self, model, file_field, file_obj, gallery, gallery_dir, owner,
|
||||||
|
post_save=None, modified_ms=None):
|
||||||
"""
|
"""
|
||||||
Create a media object, save it to the DB, schedule file save on commit.
|
Create a media object, save it to the DB, schedule file save on commit.
|
||||||
Returns True if uploaded, False if already exists.
|
Returns True if uploaded, False if already exists.
|
||||||
|
|
@ -317,11 +342,12 @@ class GalleryUpload(PermissionRequiredMixin, FormView):
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
obj.save()
|
obj.save()
|
||||||
obj.galleries.set([gallery])
|
obj.galleries.set([gallery])
|
||||||
def _save(o=obj, fp=file_path, f=file_obj, ff=file_field, ps=post_save):
|
def _save(o=obj, fp=file_path, f=file_obj, ff=file_field, ps=post_save, ms=modified_ms):
|
||||||
f.seek(0)
|
f.seek(0)
|
||||||
getattr(o, ff).save(fp, f)
|
getattr(o, ff).save(fp, f)
|
||||||
if ps:
|
if ps:
|
||||||
ps(o)
|
ps(o)
|
||||||
|
self._complete_capture_date(o, ms)
|
||||||
transaction.on_commit(_save)
|
transaction.on_commit(_save)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
@ -359,11 +385,14 @@ class GalleryUpload(PermissionRequiredMixin, FormView):
|
||||||
uploaded_photo_name = []
|
uploaded_photo_name = []
|
||||||
already_exists = 0
|
already_exists = 0
|
||||||
files = form.cleaned_data["file_field"]
|
files = form.cleaned_data["file_field"]
|
||||||
|
modified_ms = form.cleaned_data.get("file_modified")
|
||||||
for photo_file in files:
|
for photo_file in files:
|
||||||
if is_photo(photo_file):
|
if is_photo(photo_file):
|
||||||
uploaded = self._upload_media(Photo, "image", photo_file, gallery, gallery_dir, owner)
|
uploaded = self._upload_media(Photo, "image", photo_file, gallery, gallery_dir, owner,
|
||||||
|
modified_ms=modified_ms)
|
||||||
elif is_video(photo_file):
|
elif is_video(photo_file):
|
||||||
uploaded = self._upload_media(Video, "file", photo_file, gallery, gallery_dir, owner, post_save=generate_video_thumbnail)
|
uploaded = self._upload_media(Video, "file", photo_file, gallery, gallery_dir, owner,
|
||||||
|
post_save=generate_video_thumbnail, modified_ms=modified_ms)
|
||||||
else:
|
else:
|
||||||
messages.error(self.request, f"{photo_file.name} is not a recognized image or video")
|
messages.error(self.request, f"{photo_file.name} is not a recognized image or video")
|
||||||
jsondata["code"] = 400
|
jsondata["code"] = 400
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue