diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 60b03ad..837fb4f 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.4.1 +current_version = 0.4.2 commit = True tag = True diff --git a/.drone.yml b/.drone.yml index 5c94ab7..2f8bb25 100644 --- a/.drone.yml +++ b/.drone.yml @@ -44,8 +44,8 @@ steps: # commands: # - buildah login -u $DOCKER_USER -p $DOCKER_PASS -- $DOCKER_REGISTRY # - buildah manifest create ucast -# - buildah bud --tag code.thetadev.de/hsa/ucast:latest --manifest ucast --arch amd64 -f deploy/Dockerfile . -# - buildah bud --tag code.thetadev.de/hsa/ucast:latest --manifest ucast --arch arm64 -f deploy/Dockerfile . +# - buildah bud --tag code.thetadev.de/hsa/ucast:latest --manifest ucast --arch amd64 --build-arg TARGETPLATFORM=linux/amd64 -f deploy/Dockerfile . +# - buildah bud --tag code.thetadev.de/hsa/ucast:latest --manifest ucast --arch arm64 --build-arg TARGETPLATFORM=linux/arm64 -f deploy/Dockerfile . # - buildah manifest push --all ucast docker://code.thetadev.de/hsa/ucast:latest # environment: # DOCKER_REGISTRY: diff --git a/pyproject.toml b/pyproject.toml index 99ae662..106007c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ucast" -version = "0.4.1" +version = "0.4.2" description = "YouTube to Podcast converter" authors = ["Theta-Dev "] packages = [ diff --git a/ucast/__init__.py b/ucast/__init__.py index c1f2c55..8ade61d 100644 --- a/ucast/__init__.py +++ b/ucast/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.4.1" +__version__ = "0.4.2" def template_context(request): diff --git a/ucast/service/controller.py b/ucast/service/controller.py index dd260ca..0208488 100644 --- a/ucast/service/controller.py +++ b/ucast/service/controller.py @@ -1,7 +1,7 @@ import shutil from ucast.models import Channel, Video -from ucast.service import storage, util, youtube +from ucast.service import storage, util, videoutil, youtube class ChannelAlreadyExistsException(Exception): @@ -12,8 +12,10 @@ class ChannelAlreadyExistsException(Exception): def download_channel_avatar(channel: Channel): store = storage.Storage() channel_folder = store.get_or_create_channel_folder(channel.slug) - util.download_image_file(channel.avatar_url, channel_folder.file_avatar) - util.resize_avatar(channel_folder.file_avatar, channel_folder.file_avatar_sm) + util.download_image_file( + channel.avatar_url, channel_folder.file_avatar, videoutil.AVATAR_SIZE + ) + videoutil.resize_avatar(channel_folder.file_avatar, channel_folder.file_avatar_sm) def create_channel(channel_str: str) -> Channel: diff --git a/ucast/service/util.py b/ucast/service/util.py index e3ff573..ce4be40 100644 --- a/ucast/service/util.py +++ b/ucast/service/util.py @@ -4,7 +4,7 @@ import json import os import re from pathlib import Path -from typing import Any, Union +from typing import Any, Optional, Tuple, Union from urllib import parse import requests @@ -12,9 +12,6 @@ import slugify from django.utils import timezone from PIL import Image -AVATAR_SM_WIDTH = 100 -THUMBNAIL_SM_WIDTH = 360 - EMOJI_PATTERN = re.compile( "[" "\U0001F1E0-\U0001F1FF" # flags (iOS) @@ -39,13 +36,38 @@ def download_file(url: str, download_path: Path): open(download_path, "wb").write(r.content) -def download_image_file(url: str, download_path: Path): +def resize_image(img: Image, resize: Tuple[int, int]): + if img.size == resize: + return img + + w_ratio = resize[0] / img.width + h_ratio = resize[1] / img.height + box = None + + # Too tall + if h_ratio < w_ratio: + crop_height = int(img.width / resize[0] * resize[1]) + border = int((img.height - crop_height) / 2) + box = (0, border, img.width, img.height - border) + # Too wide + elif w_ratio < h_ratio: + crop_width = int(img.height / resize[1] * resize[0]) + border = int((img.width - crop_width) / 2) + box = (border, 0, img.width - border, img.height) + + return img.resize(resize, Image.Resampling.LANCZOS, box) + + +def download_image_file( + url: str, download_path: Path, resize: Optional[Tuple[int, int]] = None +): """ Download an image and convert it to the type given by the path. :param url: Image URL :param download_path: Download path + :param resize: target image size (set to None for no resizing) """ r = requests.get(url, allow_redirects=True) r.raise_for_status() @@ -55,30 +77,15 @@ def download_image_file(url: str, download_path: Path): if img_ext == "jpeg": img_ext = "jpg" + if resize: + img = resize_image(img, resize) + if "." + img_ext == download_path.suffix: open(download_path, "wb").write(r.content) else: img.save(download_path) -def resize_avatar(original_file: Path, new_file: Path): - avatar = Image.open(original_file) - avatar_new_height = int(AVATAR_SM_WIDTH / avatar.width * avatar.height) - avatar = avatar.resize( - (AVATAR_SM_WIDTH, avatar_new_height), Image.Resampling.LANCZOS - ) - avatar.save(new_file) - - -def resize_thumbnail(original_file: Path, new_file: Path): - thumbnail = Image.open(original_file) - tn_new_height = int(THUMBNAIL_SM_WIDTH / thumbnail.width * thumbnail.height) - thumbnail = thumbnail.resize( - (THUMBNAIL_SM_WIDTH, tn_new_height), Image.Resampling.LANCZOS - ) - thumbnail.save(new_file) - - def get_slug(text: str) -> str: return slugify.slugify(text, lowercase=False, separator="_") diff --git a/ucast/service/videoutil.py b/ucast/service/videoutil.py index 45e7af5..5216d93 100644 --- a/ucast/service/videoutil.py +++ b/ucast/service/videoutil.py @@ -2,6 +2,12 @@ from datetime import date from pathlib import Path from mutagen import id3 +from PIL import Image + +AVATAR_SM_WIDTH = 100 +THUMBNAIL_SM_WIDTH = 360 +THUMBNAIL_SIZE = (1280, 720) +AVATAR_SIZE = (900, 900) def tag_audio( @@ -26,3 +32,21 @@ def tag_audio( encoding=3, mime="image/png", type=3, desc="Cover", data=albumart.read() ) tag.save() + + +def resize_avatar(original_file: Path, new_file: Path): + avatar = Image.open(original_file) + avatar_new_height = int(AVATAR_SM_WIDTH / avatar.width * avatar.height) + avatar = avatar.resize( + (AVATAR_SM_WIDTH, avatar_new_height), Image.Resampling.LANCZOS + ) + avatar.save(new_file) + + +def resize_thumbnail(original_file: Path, new_file: Path): + thumbnail = Image.open(original_file) + tn_new_height = int(THUMBNAIL_SM_WIDTH / thumbnail.width * thumbnail.height) + thumbnail = thumbnail.resize( + (THUMBNAIL_SM_WIDTH, tn_new_height), Image.Resampling.LANCZOS + ) + thumbnail.save(new_file) diff --git a/ucast/service/youtube.py b/ucast/service/youtube.py index 670cc46..3fb3b0f 100644 --- a/ucast/service/youtube.py +++ b/ucast/service/youtube.py @@ -115,6 +115,7 @@ def download_thumbnail(vinfo: VideoDetails, download_path: Path): logging.info(f"downloading thumbnail {url}...") try: + # util.download_image_file(url, download_path, videoutil.THUMBNAIL_SIZE) util.download_image_file(url, download_path) return except requests.HTTPError: diff --git a/ucast/tasks/download.py b/ucast/tasks/download.py index d3bbe81..8ee1a9d 100644 --- a/ucast/tasks/download.py +++ b/ucast/tasks/download.py @@ -7,7 +7,7 @@ from yt_dlp.utils import DownloadError from ucast import queue from ucast.models import Channel, Video -from ucast.service import controller, cover, storage, util, videoutil, youtube +from ucast.service import controller, cover, storage, videoutil, youtube def _load_scraped_video(vid: youtube.VideoScraped, channel: Channel): @@ -105,7 +105,7 @@ def download_video(v_id: int): # Download/convert thumbnails tn_path = channel_folder.get_thumbnail(video.slug) youtube.download_thumbnail(details, tn_path) - util.resize_thumbnail(tn_path, channel_folder.get_thumbnail(video.slug, True)) + videoutil.resize_thumbnail(tn_path, channel_folder.get_thumbnail(video.slug, True)) cover_file = channel_folder.get_cover(video.slug) if not os.path.isfile(channel_folder.file_avatar): diff --git a/ucast/tasks/library.py b/ucast/tasks/library.py index 32c87a1..624cfc8 100644 --- a/ucast/tasks/library.py +++ b/ucast/tasks/library.py @@ -2,6 +2,7 @@ import os from django.db.models import ObjectDoesNotExist from django.utils import timezone +from PIL import Image from ucast import queue from ucast.models import Channel, Video @@ -51,6 +52,32 @@ def recreate_covers(): queue.enqueue(recreate_cover, video.id) +def resize_thumbnail(v_id: int): + try: + video = Video.objects.get(id=v_id) + except ObjectDoesNotExist: + return + + store = storage.Storage() + cf = store.get_channel_folder(video.channel.slug) + + tn_path = cf.get_thumbnail(video.slug) + tn_img = Image.open(tn_path) + if tn_img.size != videoutil.THUMBNAIL_SIZE: + tn_img = util.resize_image(tn_img, videoutil.THUMBNAIL_SIZE) + tn_img.save(tn_path) + videoutil.resize_thumbnail(tn_path, cf.get_thumbnail(video.slug, True)) + + +def resize_thumbnails(): + """ + Used to unify thumbnail sizes for the existing collection before v0.4.2. + Needs to be triggered manually: ``manage.py rqenqueue ucast.tasks.library.resize_thumbnails``. + """ + for video in Video.objects.filter(downloaded__isnull=False): + queue.enqueue(resize_thumbnail, video.id) + + def update_file_storage(): store = storage.Storage() @@ -75,7 +102,7 @@ def update_file_storage(): return if not os.path.isfile(tn_file_sm): - util.resize_thumbnail(tn_file, tn_file_sm) + videoutil.resize_thumbnail(tn_file, tn_file_sm) if not os.path.isfile(cover_file): recreate_cover(video) @@ -101,8 +128,12 @@ def update_channel_info(ch_id: int): store = storage.Storage() channel_folder = store.get_or_create_channel_folder(channel.slug) - util.download_image_file(channel_data.avatar_url, channel_folder.file_avatar) - util.resize_avatar(channel_folder.file_avatar, channel_folder.file_avatar_sm) + util.download_image_file( + channel_data.avatar_url, channel_folder.file_avatar, videoutil.AVATAR_SIZE + ) + videoutil.resize_avatar( + channel_folder.file_avatar, channel_folder.file_avatar_sm + ) channel.avatar_url = channel_data.avatar_url diff --git a/ucast/tests/_testfiles/img/normal.png b/ucast/tests/_testfiles/img/normal.png new file mode 100644 index 0000000..e15ceee Binary files /dev/null and b/ucast/tests/_testfiles/img/normal.png differ diff --git a/ucast/tests/_testfiles/img/tall.png b/ucast/tests/_testfiles/img/tall.png new file mode 100644 index 0000000..c109fc2 Binary files /dev/null and b/ucast/tests/_testfiles/img/tall.png differ diff --git a/ucast/tests/_testfiles/img/wide.png b/ucast/tests/_testfiles/img/wide.png new file mode 100644 index 0000000..1aaad30 Binary files /dev/null and b/ucast/tests/_testfiles/img/wide.png differ diff --git a/ucast/tests/conftest.py b/ucast/tests/conftest.py index 6dd6aa5..23279de 100644 --- a/ucast/tests/conftest.py +++ b/ucast/tests/conftest.py @@ -48,7 +48,7 @@ def _create_download_dir() -> Tuple[Path, TemporaryDirectory]: shutil.copyfile( tests.DIR_TESTFILES / "avatar" / f"{avatar}.jpg", cf.file_avatar ) - util.resize_avatar(cf.file_avatar, cf.file_avatar_sm) + videoutil.resize_avatar(cf.file_avatar, cf.file_avatar_sm) return tmpdir, tmpdir_o @@ -75,7 +75,7 @@ def _add_download_dir_content(): shutil.copyfile(tests.DIR_TESTFILES / "audio" / "audio1.mp3", file_audio) shutil.copyfile(tests.DIR_TESTFILES / "thumbnail" / f"{vid}.webp", file_tn) - util.resize_thumbnail(file_tn, cf.get_thumbnail(video_slug, True)) + videoutil.resize_thumbnail(file_tn, cf.get_thumbnail(video_slug, True)) cover.create_cover_file( file_tn, cf.file_avatar, diff --git a/ucast/tests/service/test_util.py b/ucast/tests/service/test_util.py index 8d98db7..671e7d9 100644 --- a/ucast/tests/service/test_util.py +++ b/ucast/tests/service/test_util.py @@ -55,28 +55,22 @@ def test_download_image_file_conv(): assert diff.getbbox() is None -def test_resize_avatar(): - tmpdir_o = tempfile.TemporaryDirectory() - tmpdir = Path(tmpdir_o.name) - source_file = tests.DIR_TESTFILES / "avatar" / "a1.jpg" - resized_file = tmpdir / "avatar.webp" +@pytest.mark.parametrize( + "src_file", + [ + "normal", + "tall", + "wide", + ], +) +def test_resize_image(src_file: str): + src_path = tests.DIR_TESTFILES / "img" / f"{src_file}.png" + src_img = Image.open(src_path) + resized = util.resize_image(src_img, (500, 250)) - util.resize_avatar(source_file, resized_file) - - resized_avatar = Image.open(resized_file) - assert resized_avatar.size == (100, 100) - - -def test_resize_thumbnail(): - tmpdir_o = tempfile.TemporaryDirectory() - tmpdir = Path(tmpdir_o.name) - source_file = tests.DIR_TESTFILES / "thumbnail" / "t1.webp" - resized_file = tmpdir / "thumbnail.webp" - - util.resize_thumbnail(source_file, resized_file) - - resized_thumbnail = Image.open(resized_file) - assert resized_thumbnail.size == (360, 202) + normal_img = Image.open(tests.DIR_TESTFILES / "img" / "normal.png") + diff = ImageChops.difference(resized, normal_img) + assert diff.getbbox() is None @pytest.mark.parametrize( diff --git a/ucast/tests/service/test_videoutil.py b/ucast/tests/service/test_videoutil.py index c7de7ae..8ac488c 100644 --- a/ucast/tests/service/test_videoutil.py +++ b/ucast/tests/service/test_videoutil.py @@ -57,3 +57,27 @@ https://youtu.be/ZPxEr4YdWt8""" expected_cover_img = Image.open(cover_file) diff = ImageChops.difference(tag_cover_img, expected_cover_img) assert diff.getbbox() is None + + +def test_resize_avatar(): + tmpdir_o = tempfile.TemporaryDirectory() + tmpdir = Path(tmpdir_o.name) + source_file = tests.DIR_TESTFILES / "avatar" / "a1.jpg" + resized_file = tmpdir / "avatar.webp" + + videoutil.resize_avatar(source_file, resized_file) + + resized_avatar = Image.open(resized_file) + assert resized_avatar.size == (100, 100) + + +def test_resize_thumbnail(): + tmpdir_o = tempfile.TemporaryDirectory() + tmpdir = Path(tmpdir_o.name) + source_file = tests.DIR_TESTFILES / "thumbnail" / "t1.webp" + resized_file = tmpdir / "thumbnail.webp" + + videoutil.resize_thumbnail(source_file, resized_file) + + resized_thumbnail = Image.open(resized_file) + assert resized_thumbnail.size == (360, 202)