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
1 change: 0 additions & 1 deletion Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,3 @@ yt-dlp = "*"
redis = "*"
hiredis = "*"
requests = {extras = ["socks"], version = "*"}
bs4 = "*"
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
Original file line number Diff line number Diff line change
@@ -1,18 +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_thumbnails',
field=models.BooleanField(default=False, help_text='Copy channel thumbnails in poster.jpg and season-poster.jpg, these may be detected and used by some media servers', verbose_name='copy channel thumbnails'),
),
]
# 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'),
),
]
33 changes: 9 additions & 24 deletions tubesync/sync/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
import uuid
import json
import re
import requests
from bs4 import BeautifulSoup
from xml.etree import ElementTree
from collections import OrderedDict
from datetime import datetime, timedelta
Expand All @@ -15,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 @@ -344,10 +343,10 @@ 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_thumbnails = models.BooleanField(
_('copy channel thumbnails'),
copy_channel_images = models.BooleanField(
_('copy channel images'),
default=False,
help_text=_('Copy channel thumbnails in poster.jpg and season-poster.jpg, these may be detected and used by some media servers')
help_text=_('Copy channel banner and avatar. These may be detected and used by some media servers')
)
copy_thumbnails = models.BooleanField(
_('copy thumbnails'),
Expand Down Expand Up @@ -490,26 +489,12 @@ def make_directory(self):
return os.makedirs(self.directory_path, exist_ok=True)

@property
def get_thumbnail_url(self):
def get_image_url(self):
if self.source_type == self.SOURCE_TYPE_YOUTUBE_PLAYLIST:
raise Exception('This source is a playlist so it doesn\'t have thumbnail.')
raise NoImageSourceException('This source is a playlist so it doesn\'t have thumbnail.')

try:
response = requests.get(self.url, cookies={'CONSENT': 'YES+1'})
response.raise_for_status()
except RequestException as e:
print(f"Error occurred while making a request to YouTube: {e}")
return None
return get_youtube_channel_image_info(self.url)

soup = BeautifulSoup(response.text, "html.parser")

try:
data = re.search(r"var ytInitialData = ({.*});", str(soup.prettify())).group(1)
json_data = json.loads(data)
return json_data["header"]["c4TabbedHeaderRenderer"]["avatar"]["thumbnails"][2]["url"]
except (KeyError, ValueError, TypeError) as e:
print(f"Error occurred while parsing YouTube JSON: {e}")
return None

def directory_exists(self):
return (os.path.isdir(self.directory_path) and
Expand Down
6 changes: 3 additions & 3 deletions 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_source_thumbnail)
download_media, rescan_media_server, download_source_images)
from .utils import delete_file


Expand Down Expand Up @@ -47,8 +47,8 @@ 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_thumbnails:
download_source_thumbnail(
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)
Expand Down
60 changes: 37 additions & 23 deletions tubesync/sync/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ def check_source_directory_exists(source_id):


@background(schedule=0)
def download_source_thumbnail(source_id):
def download_source_images(source_id):
'''
Downloads an image and save it as a local thumbnail attached to a
Source instance.
Expand All @@ -230,30 +230,44 @@ def download_source_thumbnail(source_id):
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_thumbnail(pk={source_id}) called but no '
log.error(f'Task download_source_images(pk={source_id}) called but no '
f'source exists with ID: {source_id}')
return

url = source.get_thumbnail_url
width = 400
height = 400
i = get_remote_image(url)
log.info(f'Resizing {i.width}x{i.height} thumbnail to '
f'{width}x{height}: {url}')
i = resize_image_to_height(i, width, height)
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 from {url} for source with ID: {source_id}')
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)
Expand Down
2 changes: 1 addition & 1 deletion tubesync/sync/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ 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_channel_thumbnails',
'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')
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