From db4b95766ce00690d38bff256d77fed006abc54d Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Mon, 6 Jul 2020 12:07:48 +0200 Subject: [PATCH] Genre- and collection-specific thumbnails. --- .../migrations/0018_auto_20200703_1718.py | 28 ++ .../templates/archive/file_managed.html | 253 ++++++++++-------- src/archive/views.py | 20 +- src/youtube/admin.py | 6 + .../migrations/0011_thumbnailtemplate.py | 28 ++ .../0012_move_thumbnail_definitions.py | 52 ++++ .../migrations/0013_auto_20200706_1123.py | 21 ++ src/youtube/models.py | 92 +++++-- src/youtube/templates/youtube/preview.html | 13 +- src/youtube/thumbnail.py | 18 +- src/youtube/urls.py | 2 + src/youtube/views.py | 24 +- 12 files changed, 395 insertions(+), 162 deletions(-) create mode 100644 src/archive/migrations/0018_auto_20200703_1718.py create mode 100644 src/youtube/migrations/0011_thumbnailtemplate.py create mode 100644 src/youtube/migrations/0012_move_thumbnail_definitions.py create mode 100644 src/youtube/migrations/0013_auto_20200706_1123.py diff --git a/src/archive/migrations/0018_auto_20200703_1718.py b/src/archive/migrations/0018_auto_20200703_1718.py new file mode 100644 index 0000000..2bdb10b --- /dev/null +++ b/src/archive/migrations/0018_auto_20200703_1718.py @@ -0,0 +1,28 @@ +# Generated by Django 3.0.6 on 2020-07-03 17:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('archive', '0017_auto_20200603_0011'), + ] + + operations = [ + migrations.AlterField( + model_name='audiobook', + name='mp3_status', + field=models.SmallIntegerField(choices=[(1, 'Waiting'), (10, 'Queued'), (20, 'Encoding'), (30, 'Tagging'), (40, 'Converting audio'), (50, 'Converting video'), (60, 'Assembling audio'), (70, 'Assembling video'), (80, 'Joining audio and video'), (100, 'Sending'), (110, 'Setting thumbnail')], editable=False, null=True), + ), + migrations.AlterField( + model_name='audiobook', + name='ogg_status', + field=models.SmallIntegerField(choices=[(1, 'Waiting'), (10, 'Queued'), (20, 'Encoding'), (30, 'Tagging'), (40, 'Converting audio'), (50, 'Converting video'), (60, 'Assembling audio'), (70, 'Assembling video'), (80, 'Joining audio and video'), (100, 'Sending'), (110, 'Setting thumbnail')], editable=False, null=True), + ), + migrations.AlterField( + model_name='audiobook', + name='youtube_status', + field=models.SmallIntegerField(choices=[(1, 'Waiting'), (10, 'Queued'), (20, 'Encoding'), (30, 'Tagging'), (40, 'Converting audio'), (50, 'Converting video'), (60, 'Assembling audio'), (70, 'Assembling video'), (80, 'Joining audio and video'), (100, 'Sending'), (110, 'Setting thumbnail')], editable=False, null=True), + ), + ] diff --git a/src/archive/templates/archive/file_managed.html b/src/archive/templates/archive/file_managed.html index ecb6292..d6e8be8 100644 --- a/src/archive/templates/archive/file_managed.html +++ b/src/archive/templates/archive/file_managed.html @@ -14,103 +14,59 @@
{{ alert }}
{% endfor %} -
-
-

{% trans "Publishing" %}

+ + +
+
+
+ +
+
+ {% csrf_token %} + {% bootstrap_form form %} + +
+ +
+
-
+ +
{% if audiobook.mp3_status or audiobook.ogg_status or audiobook.youtube_status %}

{% trans "Publishing pending" %}

{% csrf_token %}
- - {% if audiobook.mp3_status %} -
-

MP3

- - {% tags_table audiobook.get_mp3_tags.tags %} - -

Status: {{ audiobook.get_mp3_status_display }}

- {% endif %} - - {% if audiobook.ogg_status %} -
-

Ogg Vorbis

- - {% tags_table audiobook.get_ogg_tags.tags %} - -

Status: {{ audiobook.get_ogg_status_display }}

- {% endif %} - - {% if audiobook.youtube_status %} -
-

YouTube

- -

Status: {{ audiobook.get_youtube_status_display }}

- {% endif %} - - {% else %} - - - {% tags_table audiobook.new_publish_tags 0 %} - -
- -
-
- {% if user_can_publish %} -
- {% csrf_token %} - -
- {% else %} - Podłącz się - {% endif %} - - {% if not audiobook.mp3_published or not audiobook.ogg_published %} -
- {% csrf_token %} - -
- {% endif %} -
-
- {% if audiobook.is_youtube_publishable %} -

- - {% trans "Preview YouTube metadata" %} - -

-
- {% csrf_token %} - -
-
- {% endif %} - {% if audiobook.youtube_id %} -
- {% csrf_token %} - -
- {% endif %} - -
-
-
{% endif %} -
-
-
-
-

{% trans "MP3 file" %}

+

MP3, Ogg

+ {% if audiobook.mp3_status %} +
+

MP3

+ + {% tags_table audiobook.get_mp3_tags.tags %} + +

Status: {{ audiobook.get_mp3_status_display }}

+ {% endif %} + + {% if audiobook.ogg_status %} +
+

Ogg Vorbis

+ + {% tags_table audiobook.get_ogg_tags.tags %} + +

Status: {{ audiobook.get_ogg_status_display }}

+ {% endif %} + + {% if audiobook.mp3_file %}

{% trans "Download MP3 file." %}

{% if audiobook.mp3_published %} @@ -124,16 +80,7 @@ {% else %}

{% trans "MP3 file hasn't been generated yet." %}

{% endif %} -
-
-
-
-
-
-

{% trans "Ogg Vorbis file" %}

-
-
{% if audiobook.ogg_file %}

{% trans "Download Ogg Vorbis file." %}

{% if audiobook.ogg_published %} @@ -147,20 +94,46 @@ {% else %}

{% trans "Ogg Vorbis file hasn't been generated yet." %}

{% endif %} + + {% if user_can_publish %} +
+ {% csrf_token %} + +
+ {% else %} + Podłącz się + {% endif %} + + {% if not audiobook.mp3_published or not audiobook.ogg_published %} +
+ {% csrf_token %} + +
+ {% endif %} + +
-
-
-

{% trans "YouTube" %}

+

YouTube

+ {% if audiobook.youtube_status %} +
+

YouTube

+ +

Status: {{ audiobook.get_youtube_status_display }}

+ {% endif %} + {% if audiobook.youtube_id %} {% if audiobook.youtube_id %}

- {% trans "See on YouTube" %} + + + +

{% endif %} {% if audiobook.youtube_published %} @@ -174,48 +147,90 @@ {% else %}

{% trans "YouTube file hasn't been generated yet." %}

{% endif %} + + {% if audiobook.youtube_id %} +
+ {% csrf_token %} + +
+
+ +
+ {% csrf_token %} + +
+ {% endif %} + + {% if audiobook.is_youtube_publishable %} +
+

+ + {% trans "Preview YouTube metadata" %} + +

+
+ {% csrf_token %} + +
+ {% endif %} +
+
+ + + +
+
-

Plik źródłowy

+

Metadane dla MP3, Ogg

-

Last modified: {{ audiobook.modified }}

-

Plik źródłowy: {{ path }} - (sha1: {{ audiobook.source_sha1 }}). -

- {% multiple_tags_table tags %} - -
- {% csrf_token %} - -
- + + {% tags_table audiobook.new_publish_tags 0 %} +
-
+
-
+
-

{% trans "Update tags" %}

+

Metadane dla YouTube

- -
- {% csrf_token %} - {% bootstrap_form form %} - -
- + + {{ youtube_title }}

+ {{ youtube_description|linebreaksbr }}
+ +
+ + +
+
+

Plik źródłowy

+
+
+

Last modified: {{ audiobook.modified }}

+

Plik źródłowy: {{ path }} + (sha1: {{ audiobook.source_sha1 }}). +

+ {% multiple_tags_table tags %} + +
+ {% csrf_token %} + +
+ +
{% endblock %} diff --git a/src/archive/views.py b/src/archive/views.py index f1b1c1b..2170aff 100644 --- a/src/archive/views.py +++ b/src/archive/views.py @@ -244,12 +244,14 @@ def file_managed(request, id): except IOError: raise Http404 - path = audiobook.source_file.path[len(settings.FILES_PATH):].lstrip('/') - - # for tags update - tags = mutagen.File(audiobook.source_file.path.encode('utf-8')) - if not tags: - tags = {} + tags = {} + if audiobook.source_file: + path = audiobook.source_file.path[len(settings.FILES_PATH):].lstrip('/') + + # for tags update + tags = mutagen.File(audiobook.source_file.path.encode('utf-8')) + if not tags: + tags = {} form = AudiobookForm(instance=audiobook) user_can_publish = ( @@ -265,6 +267,12 @@ def file_managed(request, id): if set(series.values_list('index', flat=True)) != set(range(1, parts_count + 1)): alerts.append(_('Part indexes are not 1..%(parts_count)d.') % {"parts_count": parts_count}) + from youtube.models import YouTube + youtube = YouTube.objects.first() + youtube_title = youtube.get_title(audiobook) + youtube_description = youtube.get_description(audiobook) + + return render(request, "archive/file_managed.html", locals()) diff --git a/src/youtube/admin.py b/src/youtube/admin.py index 2edc745..3c36eee 100644 --- a/src/youtube/admin.py +++ b/src/youtube/admin.py @@ -14,3 +14,9 @@ admin.site.register(models.YouTube, YouTubeAdmin) admin.site.register(models.Font) + + +class ThumbnailTemplateAdmin(admin.ModelAdmin): + list_display = ['order', 'genres', 'collections'] + +admin.site.register(models.ThumbnailTemplate, ThumbnailTemplateAdmin) diff --git a/src/youtube/migrations/0011_thumbnailtemplate.py b/src/youtube/migrations/0011_thumbnailtemplate.py new file mode 100644 index 0000000..1f9999b --- /dev/null +++ b/src/youtube/migrations/0011_thumbnailtemplate.py @@ -0,0 +1,28 @@ +# Generated by Django 3.0.6 on 2020-07-03 17:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('youtube', '0010_auto_20200520_1353'), + ] + + operations = [ + migrations.CreateModel( + name='ThumbnailTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.SmallIntegerField()), + ('is_active', models.BooleanField()), + ('background', models.FileField(upload_to='youtube/thumbnail')), + ('definition', models.TextField()), + ('genres', models.CharField(blank=True, max_length=255)), + ('collections', models.CharField(blank=True, max_length=255)), + ], + options={ + 'ordering': ('order',), + }, + ), + ] diff --git a/src/youtube/migrations/0012_move_thumbnail_definitions.py b/src/youtube/migrations/0012_move_thumbnail_definitions.py new file mode 100644 index 0000000..a54cf12 --- /dev/null +++ b/src/youtube/migrations/0012_move_thumbnail_definitions.py @@ -0,0 +1,52 @@ +# Generated by Django 3.0.6 on 2020-07-03 17:18 + +from django.db import migrations +import yaml + + +def generate_versions(version_lists): + if not version_lists: + yield [] + else: + for subversion in generate_versions(version_lists[1:]): + for version in version_lists[0]: + yield [version] + subversion + + +def move_definitions(apps, schema_editor): + YouTube = apps.get_model('youtube', 'YouTube') + ThumbnailTemplate = apps.get_model('youtube', 'ThumbnailTemplate') + order = 1 + for youtube in YouTube.objects.all(): + src_def = yaml.load(youtube.thumbnail_definition) + version_lists = [ + box['versions'] + for box in src_def['boxes'] + ] + for version in generate_versions(version_lists): + defn = yaml.dump( + {"boxes": version}, + allow_unicode=True, + default_flow_style=False + ) + ThumbnailTemplate.objects.create( + order=order, + is_active=True, + background=youtube.thumbnail_template, + definition=defn, + ) + order += 1 + + +class Migration(migrations.Migration): + + dependencies = [ + ('youtube', '0011_thumbnailtemplate'), + ] + + operations = [ + migrations.RunPython( + move_definitions, + migrations.RunPython.noop + ) + ] diff --git a/src/youtube/migrations/0013_auto_20200706_1123.py b/src/youtube/migrations/0013_auto_20200706_1123.py new file mode 100644 index 0000000..74485df --- /dev/null +++ b/src/youtube/migrations/0013_auto_20200706_1123.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.6 on 2020-07-06 11:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('youtube', '0012_move_thumbnail_definitions'), + ] + + operations = [ + migrations.RemoveField( + model_name='youtube', + name='thumbnail_definition', + ), + migrations.RemoveField( + model_name='youtube', + name='thumbnail_template', + ), + ] diff --git a/src/youtube/models.py b/src/youtube/models.py index 5fe3c2b..219a682 100644 --- a/src/youtube/models.py +++ b/src/youtube/models.py @@ -4,6 +4,7 @@ from tempfile import NamedTemporaryFile from django.db import models from django.utils.translation import gettext_lazy as _ from django.template import Template, Context +import requests from apiclient import youtube_call from .utils import ( concat_audio, @@ -32,8 +33,6 @@ class YouTube(models.Model): outro_flac = models.FileField(upload_to='youtube/outro_flac', blank=True) loop_card = models.FileField(upload_to='youtube/card', blank=True) loop_video = models.FileField(upload_to='youtube/loop_video', blank=True) - thumbnail_template = models.FileField(upload_to='youtube/thumbnail', blank=True) - thumbnail_definition = models.TextField(blank=True) privacy_status = models.CharField(max_length=16, choices=[ ('public', _('public')), ('unlisted', _('unlisted')), @@ -177,27 +176,21 @@ class YouTube(models.Model): def update_thumbnail(self, audiobook): thumbnail = self.prepare_thumbnail(audiobook) - response = youtube_call( - "POST", - "https://www.googleapis.com/upload/youtube/v3/thumbnails/set", - params={'videoId': audiobook.youtube_id}, - data=thumbnail.getvalue(), - ) + if thumbnail is not None: + response = youtube_call( + "POST", + "https://www.googleapis.com/upload/youtube/v3/thumbnails/set", + params={'videoId': audiobook.youtube_id}, + data=thumbnail.getvalue(), + ) def prepare_thumbnail(self, audiobook): - img = create_thumbnail( - self.thumbnail_template.path, - self.thumbnail_definition, - { - "author": ', '.join((a['name'] for a in audiobook.book['authors'])), - "title": audiobook.book['title'], - "part": (audiobook.youtube_volume or audiobook.part_name).strip(), - }, - lambda name: Font.objects.get(name=name).truetype.path - ) - buf = io.BytesIO() - img.save(buf, format='PNG') - return buf + for thumbnail_template in ThumbnailTemplate.objects.filter(is_active=True).order_by('order'): + if not thumbnail_template.is_for_audiobook(audiobook): + continue + thumbnail = thumbnail_template.generate(audiobook) + if thumbnail is not None: + return thumbnail class Card(models.Model): @@ -216,3 +209,60 @@ class Font(models.Model): def __str__(self): return self.name + + +class ThumbnailTemplate(models.Model): + order = models.SmallIntegerField() + is_active = models.BooleanField() + background = models.FileField(upload_to='youtube/thumbnail') + definition = models.TextField() + genres = models.CharField(max_length=255, blank=True) + collections = models.CharField(max_length=255, blank=True) + + class Meta: + ordering = ('order', ) + + def generate(self, audiobook): + try: + img = create_thumbnail( + self.background.path, + self.definition, + { + "author": ', '.join((a['name'] for a in audiobook.book['authors'])), + "title": audiobook.book['title'], + "part": (audiobook.youtube_volume or audiobook.part_name).strip(), + }, + lambda name: Font.objects.get(name=name).truetype.path + ) + except Exception as e: + print(e) + return + else: + buf = io.BytesIO() + img.save(buf, format='PNG') + return buf + + def is_for_audiobook(self, audiobook): + if self.genres: + book_genres = set([g['slug'] for g in audiobook.book['genres']]) + template_genres = set([g.strip() for g in self.genres.split(',')]) + if not book_genres.intersection(template_genres): + return False + + if self.collections: + template_collections = set([g.strip() for g in self.collections.split(',')]) + in_any = False + for collection in template_collections: + apidata = requests.get( + f'https://wolnelektury.pl/api/collections/{collection}/' + ).json() + for book in apidata['books']: + if book['slug'] == audiobook.slug: + in_any = True + break + if in_any: + break + if not in_any: + return False + + return True diff --git a/src/youtube/templates/youtube/preview.html b/src/youtube/templates/youtube/preview.html index c017265..9dee0ee 100644 --- a/src/youtube/templates/youtube/preview.html +++ b/src/youtube/templates/youtube/preview.html @@ -12,10 +12,19 @@
-
+
+ {% for t in templates %} +
+ + + +
+ {% endfor %} +
+
+
-

{{ title }}

diff --git a/src/youtube/thumbnail.py b/src/youtube/thumbnail.py index 42e8070..8eb7973 100644 --- a/src/youtube/thumbnail.py +++ b/src/youtube/thumbnail.py @@ -2,12 +2,6 @@ import yaml from PIL import Image, ImageDraw, ImageFont -def drawbox(img, d, context, get_font_path): - for version in d['versions']: - if draw_version(img, version, context, get_font_path): - break - - def split_to_lines(text, draw, font, max_width): words = text.split() current = '' @@ -28,8 +22,7 @@ def split_to_lines(text, draw, font, max_width): yield current -def draw_version(img, d, context, get_font_path): - # todo: do this in a subimg +def draw_box(img, d, context, get_font_path): newimg = Image.new( 'RGBA', ( @@ -48,7 +41,11 @@ def draw_version(img, d, context, get_font_path): continue if item.get('uppercase'): text = text.upper() - font = ImageFont.truetype(get_font_path(item['font-family']), item['font-size']) + font = ImageFont.truetype( + get_font_path(item['font-family']), + item['font-size'], + layout_engine=ImageFont.LAYOUT_BASIC + ) max_width = item.get('max-width', newimg.size[0]) for line in split_to_lines(text, draw, font, max_width): @@ -66,5 +63,6 @@ def create_thumbnail(background_path, defn, context, get_font_path): img = Image.open(background_path) d = yaml.load(defn) for boxdef in d['boxes']: - drawbox(img, boxdef, context, get_font_path) + if not draw_box(img, boxdef, context, get_font_path): + raise ValueError() return img diff --git a/src/youtube/urls.py b/src/youtube/urls.py index fb9ddc7..9de274e 100644 --- a/src/youtube/urls.py +++ b/src/youtube/urls.py @@ -5,6 +5,8 @@ from . import views urlpatterns = [ url(r'^publish/(\d+)/$', views.publish, name="youtube_publish"), path('thumbnail//', views.thumbnail, name='youtube_thumbnail'), + path('thumbnail///', views.thumbnail, name='youtube_thumbnail'), path('preview//', views.Preview.as_view(), name="youtube_preview"), path('update//', views.Update.as_view(), name="youtube_update"), + path('update-thumbnail//', views.UpdateThumbnail.as_view(), name="youtube_update_thumbnail"), ] diff --git a/src/youtube/views.py b/src/youtube/views.py index 4c002ee..058178f 100644 --- a/src/youtube/views.py +++ b/src/youtube/views.py @@ -25,11 +25,16 @@ def publish(request, aid, publish=True): return redirect(reverse('file', args=[aid])) -def thumbnail(request, aid): +def thumbnail(request, aid, thumbnail_id=None): audiobook = get_object_or_404(Audiobook, id=aid) - yt = models.YouTube.objects.first() - buf = yt.prepare_thumbnail(audiobook) - return HttpResponse(buf.getvalue(), content_type='image/png') + if thumbnail_id is None: + yt = models.YouTube.objects.first() + buf = yt.prepare_thumbnail(audiobook) + else: + template = get_object_or_404(models.ThumbnailTemplate, id=thumbnail_id) + buf = template.generate(audiobook) + buf = buf.getvalue() if buf is not None else b'' + return HttpResponse(buf, content_type='image/png') class Preview(DetailView): @@ -42,6 +47,7 @@ class Preview(DetailView): ctx['data'] = yt.get_data(ctx['object']) ctx['title'] = yt.get_title(ctx['object']) ctx['description'] = yt.get_description(ctx['object']) + ctx['templates'] = models.ThumbnailTemplate.objects.all() return ctx @@ -53,3 +59,13 @@ class Update(SingleObjectMixin, View): yt = models.YouTube.objects.first() yt.update_data(self.get_object()) return redirect(reverse('file', args=[pk])) + + +@method_decorator(permission_required('archive.change_audiobook'), name='dispatch') +class UpdateThumbnail(SingleObjectMixin, View): + model = Audiobook + + def post(self, request, pk): + yt = models.YouTube.objects.first() + yt.update_thumbnail(self.get_object()) + return redirect(reverse('file', args=[pk])) -- 2.20.1