diff --git a/tubesync/sync/migrations/0021_source_copy_channel_images.py b/tubesync/sync/migrations/0021_source_copy_channel_images.py new file mode 100644 index 0000000..5d56892 --- /dev/null +++ b/tubesync/sync/migrations/0021_source_copy_channel_images.py @@ -0,0 +1,18 @@ +# Generated by nothing. Done manually by InterN0te on 2023-12-10 16:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sync', '0020_auto_20231024_1825'), + ] + + operations = [ + migrations.AddField( + model_name='source', + name='copy_channel_images', + field=models.BooleanField(default=False, help_text='Copy channel banner and avatar. These may be detected and used by some media servers', verbose_name='copy channel images'), + ), + ] diff --git a/tubesync/sync/models.py b/tubesync/sync/models.py index 356e779..77a62f6 100644 --- a/tubesync/sync/models.py +++ b/tubesync/sync/models.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta from pathlib import Path from django.conf import settings from django.db import models +from django.core.exceptions import SuspiciousOperation from django.core.files.storage import FileSystemStorage from django.core.validators import RegexValidator from django.utils.text import slugify @@ -16,7 +17,8 @@ from django.utils.translation import gettext_lazy as _ from common.errors import NoFormatException from common.utils import clean_filename from .youtube import (get_media_info as get_youtube_media_info, - download_media as download_youtube_media) + download_media as download_youtube_media, + get_channel_image_info as get_youtube_channel_image_info) from .utils import seconds_to_timestr, parse_media_format from .matching import (get_best_combined_format, get_best_audio_format, get_best_video_format) @@ -338,6 +340,11 @@ class Source(models.Model): default=FALLBACK_NEXT_BEST_HD, help_text=_('What do do when media in your source resolution and codecs is not available') ) + copy_channel_images = models.BooleanField( + _('copy channel images'), + default=False, + help_text=_('Copy channel banner and avatar. These may be detected and used by some media servers') + ) copy_thumbnails = models.BooleanField( _('copy thumbnails'), default=False, @@ -478,6 +485,14 @@ class Source(models.Model): def make_directory(self): return os.makedirs(self.directory_path, exist_ok=True) + @property + def get_image_url(self): + if self.source_type == self.SOURCE_TYPE_YOUTUBE_PLAYLIST: + raise SuspiciousOperation('This source is a playlist so it doesn\'t have thumbnail.') + + return get_youtube_channel_image_info(self.url) + + def directory_exists(self): return (os.path.isdir(self.directory_path) and os.access(self.directory_path, os.W_OK)) diff --git a/tubesync/sync/signals.py b/tubesync/sync/signals.py index b92390e..6d2f182 100644 --- a/tubesync/sync/signals.py +++ b/tubesync/sync/signals.py @@ -10,7 +10,7 @@ from .models import Source, Media, MediaServer from .tasks import (delete_task_by_source, delete_task_by_media, index_source_task, download_media_thumbnail, download_media_metadata, map_task_to_instance, check_source_directory_exists, - download_media, rescan_media_server) + download_media, rescan_media_server, download_source_images) from .utils import delete_file @@ -47,6 +47,12 @@ def source_post_save(sender, instance, created, **kwargs): priority=0, verbose_name=verbose_name.format(instance.name) ) + if instance.source_type != Source.SOURCE_TYPE_YOUTUBE_PLAYLIST and instance.copy_channel_images: + download_source_images( + str(instance.pk), + priority=0, + verbose_name=verbose_name.format(instance.name) + ) if instance.index_schedule > 0: delete_task_by_source('sync.tasks.index_source_task', instance.pk) log.info(f'Scheduling media indexing for source: {instance.name}') diff --git a/tubesync/sync/tasks.py b/tubesync/sync/tasks.py index 5ecfd5e..cd16326 100644 --- a/tubesync/sync/tasks.py +++ b/tubesync/sync/tasks.py @@ -14,6 +14,7 @@ from datetime import timedelta, datetime from shutil import copyfile from PIL import Image from django.conf import settings +from django.core.files.base import ContentFile from django.core.files.uploadedfile import SimpleUploadedFile from django.utils import timezone from django.db.utils import IntegrityError @@ -219,6 +220,56 @@ def check_source_directory_exists(source_id): source.make_directory() +@background(schedule=0) +def download_source_images(source_id): + ''' + Downloads an image and save it as a local thumbnail attached to a + Source instance. + ''' + try: + source = Source.objects.get(pk=source_id) + except Source.DoesNotExist: + # Task triggered but the source no longer exists, do nothing + log.error(f'Task download_source_images(pk={source_id}) called but no ' + f'source exists with ID: {source_id}') + return + avatar, banner = source.get_image_url + log.info(f'Thumbnail URL for source with ID: {source_id} ' + f'Avatar: {avatar} ' + f'Banner: {banner}') + if banner != None: + url = banner + i = get_remote_image(url) + image_file = BytesIO() + i.save(image_file, 'JPEG', quality=85, optimize=True, progressive=True) + + for file_name in ["banner.jpg", "background.jpg"]: + # Reset file pointer to the beginning for the next save + image_file.seek(0) + # Create a Django ContentFile from BytesIO stream + django_file = ContentFile(image_file.read()) + file_path = source.directory_path / file_name + with open(file_path, 'wb') as f: + f.write(django_file.read()) + + if avatar != None: + url = avatar + i = get_remote_image(url) + image_file = BytesIO() + i.save(image_file, 'JPEG', quality=85, optimize=True, progressive=True) + + for file_name in ["poster.jpg", "season-poster.jpg"]: + # Reset file pointer to the beginning for the next save + image_file.seek(0) + # Create a Django ContentFile from BytesIO stream + django_file = ContentFile(image_file.read()) + file_path = source.directory_path / file_name + with open(file_path, 'wb') as f: + f.write(django_file.read()) + + log.info(f'Thumbnail downloaded for source with ID: {source_id}') + + @background(schedule=0) def download_media_metadata(media_id): ''' diff --git a/tubesync/sync/views.py b/tubesync/sync/views.py index 0b808eb..a97b1ea 100644 --- a/tubesync/sync/views.py +++ b/tubesync/sync/views.py @@ -297,8 +297,8 @@ class EditSourceMixin: fields = ('source_type', 'key', 'name', 'directory', 'filter_text', 'media_format', 'index_schedule', 'download_media', 'download_cap', 'delete_old_media', 'delete_removed_media', 'days_to_keep', 'source_resolution', 'source_vcodec', - 'source_acodec', 'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_thumbnails', - 'write_nfo', 'write_json', 'embed_metadata', 'embed_thumbnail', + 'source_acodec', 'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_channel_images', + 'copy_thumbnails', 'write_nfo', 'write_json', 'embed_metadata', 'embed_thumbnail', 'enable_sponsorblock', 'sponsorblock_categories', 'write_subtitles', 'auto_subtitles', 'sub_langs') errors = { diff --git a/tubesync/sync/youtube.py b/tubesync/sync/youtube.py index cdf2647..78b130c 100644 --- a/tubesync/sync/youtube.py +++ b/tubesync/sync/youtube.py @@ -35,6 +35,35 @@ def get_yt_opts(): opts.update({'cookiefile': cookie_file_path}) return opts +def get_channel_image_info(url): + opts = get_yt_opts() + opts.update({ + 'skip_download': True, + 'forcejson': True, + 'simulate': True, + 'logger': log, + 'extract_flat': True, # Change to False to get detailed info + }) + + with yt_dlp.YoutubeDL(opts) as y: + try: + response = y.extract_info(url, download=False) + + avatar_url = None + banner_url = None + for thumbnail in response['thumbnails']: + if thumbnail['id'] == 'avatar_uncropped': + avatar_url = thumbnail['url'] + if thumbnail['id'] == 'banner_uncropped': + banner_url = thumbnail['url'] + if banner_url != None and avatar_url != None: + break + + return avatar_url, banner_url + except yt_dlp.utils.DownloadError as e: + raise YouTubeError(f'Failed to extract channel info for "{url}": {e}') from e + + def get_media_info(url): '''