Skip to content

Commit ef3092f

Browse files
committed
Add fields for talk YouTube video URL and video image
The image of a YouTube video is automatically downloaded when the talk is saved through the Django admin. This allows serving the video thumbnail without tracking the user by YouTube.
1 parent 1fffc2c commit ef3092f

File tree

3 files changed

+169
-4
lines changed

3 files changed

+169
-4
lines changed

program/admin.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1+
from pathlib import PurePath
2+
from urllib.parse import quote
3+
4+
import requests
15
from django.contrib import admin
6+
from django.core.files.base import ContentFile
27
from django.db import transaction
8+
from django.utils.html import format_html
39

410
from program import pretalx, pretalx_sync
511
from program.models import Room, Slot, Speaker, Talk, Utility, Workshop
@@ -140,7 +146,6 @@ class TalkAdmin(admin.ModelAdmin):
140146
"fields": [
141147
"pretalx_code",
142148
"og_image",
143-
"video_id",
144149
],
145150
},
146151
),
@@ -153,6 +158,15 @@ class TalkAdmin(admin.ModelAdmin):
153158
],
154159
},
155160
),
161+
(
162+
"Slides and Video",
163+
{
164+
"fields": [
165+
"video_url",
166+
"video_image_html",
167+
],
168+
},
169+
),
156170
(
157171
"Talk info (edit in pretalx)",
158172
{
@@ -181,10 +195,29 @@ class TalkAdmin(admin.ModelAdmin):
181195
"minimum_python_knowledge",
182196
"minimum_topic_knowledge",
183197
"type",
198+
"video_image_html",
184199
]
185200
actions = [make_public, make_not_public, talk_update_from_pretalx]
186201
change_form_template = "program/admin/change_form_session.html"
187202

203+
@admin.display(description="Video Image")
204+
def video_image_html(self, obj: Talk):
205+
if not obj.video_image:
206+
return "(no image)"
207+
208+
html = (
209+
'<a href="{image_url}" style="display: inline-block">'
210+
'<img src="{image_url}" height="180"/><br>'
211+
'<span style="display: inline-block; margin-top: 1ex;">{image_name}</span>'
212+
"</a>"
213+
)
214+
215+
return format_html(
216+
html,
217+
image_url=obj.video_image.url,
218+
image_name=obj.video_image.name,
219+
)
220+
188221
def get_queryset(self, request):
189222
qs = super().get_queryset(request)
190223
qs = qs.prefetch_related("talk_speakers")
@@ -201,12 +234,52 @@ def get_readonly_fields(self, request, obj=None):
201234
return ro_fields
202235

203236
def save_model(self, request, obj: Talk, form, change: bool) -> None:
237+
if change:
238+
self._update_video_image(obj)
239+
204240
obj.save()
205241

206242
if not change and obj.pretalx_code:
207243
sync = create_pretalx_sync()
208244
sync.update_talks([obj])
209245

246+
def _update_video_image(self, talk: Talk):
247+
video_id = talk.video_id
248+
if not video_id:
249+
# Delete the existing image, if any.
250+
if talk.video_image:
251+
talk.video_image.delete(save=False)
252+
return
253+
254+
# Get the video ID of the current image:
255+
# the image is always named <video_id>.jpg
256+
image_video_id: str | None = None
257+
if talk.video_image:
258+
image_path = PurePath(talk.video_image.name)
259+
image_video_id = image_path.stem
260+
261+
# Check if the video ID has changed and download a new image when necessary.
262+
if video_id != image_video_id:
263+
image_data = self._download_youtube_video_image(video_id)
264+
talk.video_image.save(
265+
name=image_data.name,
266+
content=image_data,
267+
save=False,
268+
)
269+
270+
def _download_youtube_video_image(self, video_id: str) -> ContentFile:
271+
image_url = self._format_youtube_video_image_url(video_id)
272+
273+
with requests.get(image_url, timeout=30) as image_response:
274+
image_response.raise_for_status()
275+
return ContentFile(
276+
content=image_response.content,
277+
name=f"{video_id}.jpg",
278+
)
279+
280+
def _format_youtube_video_image_url(self, video_id):
281+
return f"https://img.youtube.com/vi/{quote(video_id)}/maxresdefault.jpg"
282+
210283

211284
@admin.action(description="Update from pretalx")
212285
def workshop_update_from_pretalx(modeladmin, request, queryset):
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Generated by Django 4.2.1 on 2023-11-01 10:05
2+
3+
import re
4+
5+
import django.core.validators
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
dependencies = [
11+
("program", "0021_alter_room_options_remove_room_floor_room_order_and_more"),
12+
]
13+
14+
operations = [
15+
migrations.RemoveField(
16+
model_name="talk",
17+
name="video_id",
18+
),
19+
migrations.AddField(
20+
model_name="talk",
21+
name="video_image",
22+
field=models.ImageField(
23+
blank=True, null=True, upload_to="video-images/session/"
24+
),
25+
),
26+
migrations.AddField(
27+
model_name="talk",
28+
name="video_url",
29+
field=models.CharField(
30+
blank=True,
31+
help_text="YouTube video URL: <code>https://www.youtube.com/watch?v=&lt;VIDEO_ID&gt;</code>. Do not include any additional parameters, such as start time or tracking (UTM) parameters.",
32+
max_length=1024,
33+
null=True,
34+
validators=[
35+
django.core.validators.RegexValidator(
36+
code="invalid_youtube_url",
37+
message="Invalid YouTube URL format.",
38+
regex=re.compile(
39+
"^https://www\\.youtube\\.com/watch\\?v=(?P<video_id>[0-9A-Za-z_-]{11,})$"
40+
),
41+
)
42+
],
43+
verbose_name="Video URL",
44+
),
45+
),
46+
]

program/models.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import datetime
2+
import re
23
from typing import Any
34

5+
from django.core import validators
46
from django.core.exceptions import ValidationError
57
from django.db import models
68
from django.urls import reverse
9+
from django.utils.safestring import mark_safe
710

811
from program import pretalx
912

13+
YOUTUBE_VIDEO_PATTERN = re.compile(
14+
pattern=r"^https://www\.youtube\.com/watch\?v=(?P<video_id>[0-9A-Za-z_-]{11,})$",
15+
)
16+
1017

1118
class Speaker(models.Model):
1219
PRETALX_FIELDS = [
@@ -242,10 +249,40 @@ def update_from_pretalx(self, pretalx_submission: dict[str, Any]) -> None:
242249
class Talk(Session):
243250
PRETALX_FIELDS = Session.PRETALX_FIELDS + ["is_keynote"]
244251

245-
video_id = models.CharField(
246-
max_length=100, default="", blank=True, help_text="YouTube ID (from URL)"
247-
)
248252
is_keynote = models.BooleanField(default=False, blank=True)
253+
video_url = models.CharField(
254+
max_length=1024,
255+
null=True,
256+
blank=True,
257+
validators=[
258+
validators.RegexValidator(
259+
regex=YOUTUBE_VIDEO_PATTERN,
260+
message="Invalid YouTube URL format.",
261+
code="invalid_youtube_url",
262+
),
263+
],
264+
verbose_name="Video URL",
265+
help_text=mark_safe(
266+
"YouTube video URL: "
267+
"<code>https://www.youtube.com/watch?v=&lt;VIDEO_ID&gt;</code>. "
268+
"Do not include any additional parameters, "
269+
"such as start time or tracking (UTM) parameters."
270+
),
271+
)
272+
"""
273+
URL of the video with session recording. The URL should not contain any additional
274+
parameters, such as start time or tracking (UTM) parameters.
275+
276+
Currently only YouTube videos are supported.
277+
"""
278+
video_image = models.ImageField(
279+
null=True,
280+
blank=True,
281+
upload_to="video-images/session/",
282+
)
283+
"""
284+
Image that will be used as a placeholder for the video.
285+
"""
249286

250287
@property
251288
def speakers(self):
@@ -255,6 +292,15 @@ def speakers(self):
255292
return self.public_speakers
256293
return self.talk_speakers.all().filter(is_public=True)
257294

295+
@property
296+
def video_id(self) -> str | None:
297+
if not self.video_url:
298+
return None
299+
match = YOUTUBE_VIDEO_PATTERN.match(self.video_url)
300+
if not match:
301+
return None
302+
return match.group("video_id")
303+
258304
def update_from_pretalx(self, pretalx_submission: dict[str, Any]) -> None:
259305
# Note: remember to update the PRETALX_FIELDS class variable
260306
# when adding/removing fields synced with pretalx.

0 commit comments

Comments
 (0)