Add video support with unified media display.
All checks were successful
Docker / build (release) Successful in 9s

This commit is contained in:
krek0 2026-05-16 15:13:14 +02:00
parent a634cc88bd
commit f4052a3d99
16 changed files with 700 additions and 224 deletions

View file

@ -63,6 +63,15 @@ else:
return os.path.join("photos", fn)
def get_video_storage_path(instance, filename):
fn = (
unicodedata.normalize("NFKD", force_str(filename))
.encode("ascii", "ignore")
.decode("ascii")
)
return os.path.join("videos", fn)
# Exif Orientation values
# Value 0thRow 0thColumn
# 1 top left
@ -174,6 +183,12 @@ class Gallery(models.Model):
verbose_name=_("photos"),
blank=True,
)
videos = models.ManyToManyField(
"photologue.Video",
related_name="galleries",
verbose_name=_("videos"),
blank=True,
)
is_public = models.BooleanField(
_("is public"),
default=False,
@ -216,20 +231,77 @@ class Gallery(models.Model):
"""Return a count of private photos in this gallery."""
return self.photos.filter(is_public=False).count()
def media_count(self, public=True):
"""Return a count of all photos and videos in this gallery."""
return self.photo_count(public) + (
self.videos.filter(is_public=True).count() if public else self.videos.count()
)
photo_count.short_description = _("count")
photo_private_count.short_description = _("private count")
class ImageModel(models.Model):
class MediaModel(models.Model):
"""Abstract base model with fields shared between Photo and Video."""
title = models.CharField(_("title"), max_length=250)
slug = models.SlugField(
_("slug"),
unique=True,
max_length=250,
help_text=_('A "slug" is a unique URL-friendly title for an object.'),
)
caption = models.TextField(_("caption"), blank=True)
date_added = models.DateTimeField(_("date added"), default=now)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
verbose_name=_("owner"),
)
is_public = models.BooleanField(
_("is public"),
default=True,
help_text=_("Public photographs will be displayed in the default views."),
)
thumbnail_width = models.PositiveIntegerField(editable=False, null=True)
thumbnail_height = models.PositiveIntegerField(editable=False, null=True)
is_video = False
class Meta:
abstract = True
def __str__(self):
return self.title
@property
def model_name(self):
return self._meta.model_name
@property
def file_path(self):
raise NotImplementedError
def get_admin_url(self):
return reverse(f"admin:photologue_{self._meta.model_name}_change", args=[self.pk])
def get_download_url(self):
raise NotImplementedError
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
super().save(*args, **kwargs)
class Photo(MediaModel):
image = models.ImageField(
_("image"),
max_length=IMAGE_FIELD_MAX_LENGTH,
upload_to=get_storage_path,
width_field="image_width",
height_field="image_height",
width_field="thumbnail_width",
height_field="thumbnail_height",
)
image_width = models.PositiveIntegerField(editable=False, null=True)
image_height = models.PositiveIntegerField(editable=False, null=True)
date_taken = models.DateTimeField(
_("date taken"),
null=True,
@ -244,9 +316,24 @@ class ImageModel(models.Model):
default="center",
choices=CROP_ANCHOR_CHOICES,
)
license = models.CharField(
max_length=255,
blank=True,
verbose_name=_("license"),
)
is_video = False # class attribute used in templates to distinguish media types
class Meta:
abstract = True
# We do not have a reliable date for ordering, so let's use
# the title which is incremented by most cameras
ordering = ["title"]
get_latest_by = "date_added"
verbose_name = _("photo")
verbose_name_plural = _("photos")
permissions = [
("can_resolve_censorship", "Can resolve censorship (restore private photos)"),
]
def exif(self, file=None):
try:
@ -505,6 +592,11 @@ class ImageModel(models.Model):
)
except Exception:
logger.error("Failed to read EXIF DateTimeOriginal", exc_info=True)
# If crop_from property has been changed on existing image,
# force image recreation
current = Photo.objects.get(pk=self.pk) if self.pk else None
if current and (current.crop_from != self.crop_from):
recreate = True
super().save(*args, **kwargs)
self.pre_cache(recreate)
@ -524,61 +616,16 @@ class ImageModel(models.Model):
super().delete()
self.image.storage.delete(self.image.name)
class Photo(ImageModel):
title = models.CharField(_("title"), max_length=250)
slug = models.SlugField(
_("slug"),
unique=True,
max_length=250,
help_text=_('A "slug" is a unique URL-friendly title for an object.'),
)
caption = models.TextField(_("caption"), blank=True)
date_added = models.DateTimeField(_("date added"), default=now)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
verbose_name=_("owner"),
)
license = models.CharField(
max_length=255,
blank=True,
verbose_name=_("license"),
)
is_public = models.BooleanField(
_("is public"),
default=True,
help_text=_("Public photographs will be displayed in the default views."),
)
class Meta:
# We do not have a reliable date for ordering, so let's use
# the title which is incremented by most cameras
ordering = ["title"]
get_latest_by = "date_added"
verbose_name = _("photo")
verbose_name_plural = _("photos")
permissions = [
("can_resolve_censorship", "Can resolve censorship (restore private photos)"),
]
def __str__(self):
return self.title
def save(self, *args, **kwargs):
# If crop_from property has been changed on existing image,
# update kwargs to force image recreation in parent class
current = Photo.objects.get(pk=self.pk) if self.pk else None
if current and (current.crop_from != self.crop_from):
kwargs.update(recreate=True)
if self.slug is None:
self.slug = slugify(self.title)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse("photologue:pl-photo", args=[self.pk])
def get_download_url(self):
return self.image.url
@property
def file_path(self):
return self.image.path
def public_galleries(self):
"""Return the public galleries to which this photo belongs."""
return self.galleries
@ -699,11 +746,10 @@ class PhotoSize(models.Model):
return self.name
def clear_cache(self):
for cls in ImageModel.__subclasses__():
for obj in cls.objects.all():
obj.remove_size(self)
if self.pre_cache:
obj.create_size(self)
for obj in Photo.objects.all():
obj.remove_size(self)
if self.pre_cache:
obj.create_size(self)
PhotoSizeCache().reset()
def clean(self):
@ -782,6 +828,67 @@ def init_size_method_map():
}
class Video(MediaModel):
file = models.FileField(
_("video file"),
max_length=200,
upload_to=get_video_storage_path,
)
thumbnail = models.ImageField(
_("thumbnail"),
max_length=200,
upload_to=get_video_storage_path,
blank=True,
width_field="thumbnail_width",
height_field="thumbnail_height",
)
is_video = True # class attribute used in templates to distinguish media types
class Meta:
ordering = ["title"]
get_latest_by = "date_added"
verbose_name = _("video")
verbose_name_plural = _("videos")
def get_thumbnail_url(self):
missing = not self.thumbnail or not os.path.exists(self.thumbnail.path)
if missing and self.file:
from .utils import generate_video_thumbnail
generate_video_thumbnail(self)
return self.thumbnail.url if self.thumbnail else ''
def get_display_url(self):
return self.file.url
def get_download_url(self):
return self.file.url
@property
def file_path(self):
return self.file.path
def get_mime_type(self):
import mimetypes
mime, _ = mimetypes.guess_type(self.file.name)
return mime or 'video/mp4'
def delete(self):
assert (
self._get_pk_val() is not None
), "%s object can't be deleted because its %s attribute is set to None." % (
self._meta.object_name,
self._meta.pk.attname,
)
# Files associated to a FileField have to be manually deleted:
# https://docs.djangoproject.com/en/dev/releases/1.3/#deleting-a-model-doesn-t-delete-associated-files
super().delete()
if self.thumbnail:
self.thumbnail.storage.delete(self.thumbnail.name)
if self.file:
self.file.storage.delete(self.file.name)
class Tag(models.Model):
name = models.CharField(
max_length=250,