Add video support with unified media display.
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
a634cc88bd
commit
f4052a3d99
16 changed files with 700 additions and 224 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue