Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added an option to export channels thumbnails for Jellyfin #449

Merged
merged 12 commits into from
Feb 26, 2024
7 changes: 7 additions & 0 deletions tubesync/common/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,10 @@ class DatabaseConnectionError(Exception):
Raised when parsing or initially connecting to a database.
'''
pass


class NoImageSourceException(Exception):
'''
Raised when images are requested from a source of a type that does not have any.
'''
pass
18 changes: 18 additions & 0 deletions tubesync/sync/migrations/0021_source_copy_channel_images.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
18 changes: 16 additions & 2 deletions tubesync/sync/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
from django.utils.text import slugify
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from common.errors import NoFormatException
from common.errors import NoFormatException, NoImageSourceException
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)
Expand Down Expand Up @@ -342,6 +343,11 @@ class IndexSchedule(models.IntegerChoices):
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,
Expand Down Expand Up @@ -482,6 +488,14 @@ def type_directory_path(self):
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 NoImageSourceException('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))
Expand Down
8 changes: 7 additions & 1 deletion tubesync/sync/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
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


Expand Down Expand Up @@ -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}')
Expand Down
51 changes: 51 additions & 0 deletions tubesync/sync/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
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
Expand Down Expand Up @@ -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):
'''
Expand Down
4 changes: 2 additions & 2 deletions tubesync/sync/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
29 changes: 29 additions & 0 deletions tubesync/sync/youtube.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,35 @@ def get_yt_opts():
opts.update({'cookiefile': cookie_file_path})
return opts

def get_channel_image_info(url):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One final comment here, this info is almost certainly in the metadata stored in Media.metadata - you can likely just load it straight out of the JSON without having to invoke yt-dlp at all.

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):
'''
Expand Down