Merge branch 'main' into main-extractChannelThumbnail

This commit is contained in:
meeb 2024-02-26 16:50:29 +11:00 committed by GitHub
commit af3cf7ba63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 93 additions and 56 deletions

View File

@ -84,7 +84,7 @@ With a lot of media files the `sync_media` table grows in size quickly.
You can save space using column compression using the following steps while using MariaDB: You can save space using column compression using the following steps while using MariaDB:
1. Stop tubesync 1. Stop tubesync
2. Execute `ALTER TABLE sync_source MODIFY metadata LONGTEXT COMPRESSED;` on database tubesync 2. Execute `ALTER TABLE sync_media MODIFY metadata LONGTEXT COMPRESSED;` on database tubesync
3. Start tunesync and confirm the connection still works. 3. Start tunesync and confirm the connection still works.
## Docker Compose ## Docker Compose

View File

@ -0,0 +1,17 @@
# Generated by pac
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sync', '0020_auto_20231024_1825'),
]
operations = [
migrations.AddField(
model_name='source',
name='delete_files_on_disk',
field=models.BooleanField(default=False, help_text='Delete files on disk when they are removed from TubeSync', verbose_name='delete files on disk'),
),
]

View File

@ -109,7 +109,6 @@ class Source(models.Model):
EXTENSION_MKV = 'mkv' EXTENSION_MKV = 'mkv'
EXTENSIONS = (EXTENSION_M4A, EXTENSION_OGG, EXTENSION_MKV) EXTENSIONS = (EXTENSION_M4A, EXTENSION_OGG, EXTENSION_MKV)
# as stolen from: https://wiki.sponsor.ajay.app/w/Types / https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/postprocessor/sponsorblock.py # as stolen from: https://wiki.sponsor.ajay.app/w/Types / https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/postprocessor/sponsorblock.py
SPONSORBLOCK_CATEGORIES_CHOICES = ( SPONSORBLOCK_CATEGORIES_CHOICES = (
('sponsor', 'Sponsor'), ('sponsor', 'Sponsor'),
@ -123,15 +122,14 @@ class Source(models.Model):
) )
sponsorblock_categories = CommaSepChoiceField( sponsorblock_categories = CommaSepChoiceField(
_(''), _(''),
possible_choices=SPONSORBLOCK_CATEGORIES_CHOICES, possible_choices=SPONSORBLOCK_CATEGORIES_CHOICES,
all_choice="all", all_choice='all',
allow_all=True, allow_all=True,
all_label="(all options)", all_label='(all options)',
default="all", default='all',
help_text=_("Select the sponsorblocks you want to enforce") help_text=_('Select the sponsorblocks you want to enforce')
) )
embed_metadata = models.BooleanField( embed_metadata = models.BooleanField(
_('embed metadata'), _('embed metadata'),
default=False, default=False,
@ -142,14 +140,12 @@ class Source(models.Model):
default=False, default=False,
help_text=_('Embed thumbnail into the file') help_text=_('Embed thumbnail into the file')
) )
enable_sponsorblock = models.BooleanField( enable_sponsorblock = models.BooleanField(
_('enable sponsorblock'), _('enable sponsorblock'),
default=True, default=True,
help_text=_('Use SponsorBlock?') help_text=_('Use SponsorBlock?')
) )
# Fontawesome icons used for the source on the front end # Fontawesome icons used for the source on the front end
ICONS = { ICONS = {
SOURCE_TYPE_YOUTUBE_CHANNEL: '<i class="fab fa-youtube"></i>', SOURCE_TYPE_YOUTUBE_CHANNEL: '<i class="fab fa-youtube"></i>',
@ -302,6 +298,11 @@ class Source(models.Model):
default=False, default=False,
help_text=_('Delete media that is no longer on this playlist') help_text=_('Delete media that is no longer on this playlist')
) )
delete_files_on_disk = models.BooleanField(
_('delete files on disk'),
default=False,
help_text=_('Delete files on disk when they are removed from TubeSync')
)
source_resolution = models.CharField( source_resolution = models.CharField(
_('source resolution'), _('source resolution'),
max_length=8, max_length=8,
@ -1406,7 +1407,7 @@ class Media(models.Model):
# Download the media with youtube-dl # Download the media with youtube-dl
download_youtube_media(self.url, format_str, self.source.extension, download_youtube_media(self.url, format_str, self.source.extension,
str(self.filepath), self.source.write_json, str(self.filepath), self.source.write_json,
self.source.sponsorblock_categories, self.source.embed_thumbnail, self.source.sponsorblock_categories.selected_choices, self.source.embed_thumbnail,
self.source.embed_metadata, self.source.enable_sponsorblock, self.source.embed_metadata, self.source.enable_sponsorblock,
self.source.write_subtitles, self.source.auto_subtitles,self.source.sub_langs ) self.source.write_subtitles, self.source.auto_subtitles,self.source.sub_langs )
# Return the download paramaters # Return the download paramaters

View File

@ -1,4 +1,5 @@
import os import os
import glob
from django.conf import settings from django.conf import settings
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete from django.db.models.signals import pre_save, post_save, pre_delete, post_delete
from django.dispatch import receiver from django.dispatch import receiver
@ -80,6 +81,7 @@ def source_pre_delete(sender, instance, **kwargs):
media.delete() media.delete()
@receiver(post_delete, sender=Source) @receiver(post_delete, sender=Source)
def source_post_delete(sender, instance, **kwargs): def source_post_delete(sender, instance, **kwargs):
# Triggered after a source is deleted # Triggered after a source is deleted
@ -228,6 +230,16 @@ def media_pre_delete(sender, instance, **kwargs):
if thumbnail_url: if thumbnail_url:
delete_task_by_media('sync.tasks.download_media_thumbnail', delete_task_by_media('sync.tasks.download_media_thumbnail',
(str(instance.pk), thumbnail_url)) (str(instance.pk), thumbnail_url))
if instance.source.delete_files_on_disk and (instance.media_file or instance.thumb):
# Delete all media files if it contains filename
filepath = instance.media_file.path if instance.media_file else instance.thumb.path
barefilepath, fileext = os.path.splitext(filepath)
# Get all files that start with the bare file path
all_related_files = glob.glob(f'{barefilepath}.*')
for file in all_related_files:
log.info(f'Deleting file for: {instance} path: {file}')
delete_file(file)
@receiver(post_delete, sender=Media) @receiver(post_delete, sender=Media)

View File

@ -344,6 +344,11 @@ def download_media_thumbnail(media_id, url):
except Media.DoesNotExist: except Media.DoesNotExist:
# Task triggered but the media no longer exists, do nothing # Task triggered but the media no longer exists, do nothing
return return
if media.skip:
# Media was toggled to be skipped after the task was scheduled
log.warn(f'Download task triggered for media: {media} (UUID: {media.pk}) but '
f'it is now marked to be skipped, not downloading thumbnail')
return
width = getattr(settings, 'MEDIA_THUMBNAIL_WIDTH', 430) width = getattr(settings, 'MEDIA_THUMBNAIL_WIDTH', 430)
height = getattr(settings, 'MEDIA_THUMBNAIL_HEIGHT', 240) height = getattr(settings, 'MEDIA_THUMBNAIL_HEIGHT', 240)
i = get_remote_image(url) i = get_remote_image(url)

View File

@ -9,8 +9,8 @@
<p> <p>
Are you sure you want to delete this source? Deleting a source is permanent. Are you sure you want to delete this source? Deleting a source is permanent.
By default, deleting a source does not delete any saved media files. You can By default, deleting a source does not delete any saved media files. You can
tick the &quot;also delete downloaded media&quot; checkbox to also remove save <strong>tick the &quot;also delete downloaded media&quot; checkbox to also remove directory {{ source.directory_path }}
media when you delete the source. Deleting a source cannot be undone. </strong>when you delete the source. Deleting a source cannot be undone.
</p> </p>
</div> </div>
</div> </div>

View File

@ -122,6 +122,10 @@
<tr title="Delete media that is no longer on this playlist?"> <tr title="Delete media that is no longer on this playlist?">
<td class="hide-on-small-only">Delete removed media</td> <td class="hide-on-small-only">Delete removed media</td>
<td><span class="hide-on-med-and-up">Delete removed media<br></span><strong>{% if source.delete_removed_media %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td> <td><span class="hide-on-med-and-up">Delete removed media<br></span><strong>{% if source.delete_removed_media %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
</tr>
<tr title="Delete files on disk when they are removed from TubeSync?">
<td class="hide-on-small-only">Delete files on disk</td>
<td><span class="hide-on-med-and-up">Delete files on disk<br></span><strong>{% if source.delete_files_on_disk %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
</tr> </tr>
{% if source.delete_old_media and source.days_to_keep > 0 %} {% if source.delete_old_media and source.days_to_keep > 0 %}
<tr title="Days after which your media from this source will be locally deleted"> <tr title="Days after which your media from this source will be locally deleted">

View File

@ -1,7 +1,9 @@
import glob
import os import os
import json import json
from base64 import b64decode from base64 import b64decode
import pathlib import pathlib
import shutil
import sys import sys
from django.conf import settings from django.conf import settings
from django.http import FileResponse, Http404, HttpResponseNotFound, HttpResponseRedirect from django.http import FileResponse, Http404, HttpResponseNotFound, HttpResponseRedirect
@ -59,7 +61,7 @@ class DashboardView(TemplateView):
# Disk usage # Disk usage
disk_usage = Media.objects.filter( disk_usage = Media.objects.filter(
downloaded=True, downloaded_filesize__isnull=False downloaded=True, downloaded_filesize__isnull=False
).aggregate(Sum('downloaded_filesize')) ).defer('metadata').aggregate(Sum('downloaded_filesize'))
data['disk_usage_bytes'] = disk_usage['downloaded_filesize__sum'] data['disk_usage_bytes'] = disk_usage['downloaded_filesize__sum']
if not data['disk_usage_bytes']: if not data['disk_usage_bytes']:
data['disk_usage_bytes'] = 0 data['disk_usage_bytes'] = 0
@ -71,11 +73,11 @@ class DashboardView(TemplateView):
# Latest downloads # Latest downloads
data['latest_downloads'] = Media.objects.filter( data['latest_downloads'] = Media.objects.filter(
downloaded=True, downloaded_filesize__isnull=False downloaded=True, downloaded_filesize__isnull=False
).order_by('-download_date')[:10] ).defer('metadata').order_by('-download_date')[:10]
# Largest downloads # Largest downloads
data['largest_downloads'] = Media.objects.filter( data['largest_downloads'] = Media.objects.filter(
downloaded=True, downloaded_filesize__isnull=False downloaded=True, downloaded_filesize__isnull=False
).order_by('-downloaded_filesize')[:10] ).defer('metadata').order_by('-downloaded_filesize')[:10]
# UID and GID # UID and GID
data['uid'] = os.getuid() data['uid'] = os.getuid()
data['gid'] = os.getgid() data['gid'] = os.getgid()
@ -298,6 +300,8 @@ class EditSourceMixin:
'index_schedule', 'download_media', 'download_cap', 'delete_old_media', 'index_schedule', 'download_media', 'download_cap', 'delete_old_media',
'delete_removed_media', 'days_to_keep', 'source_resolution', 'source_vcodec', 'delete_removed_media', 'days_to_keep', 'source_resolution', 'source_vcodec',
'source_acodec', 'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_channel_images', 'source_acodec', 'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_channel_images',
'delete_removed_media', 'delete_files_on_disk', 'days_to_keep', 'source_resolution',
'source_vcodec', 'source_acodec', 'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_channel_images',
'copy_thumbnails', 'write_nfo', 'write_json', 'embed_metadata', 'embed_thumbnail', 'copy_thumbnails', 'write_nfo', 'write_json', 'embed_metadata', 'embed_thumbnail',
'enable_sponsorblock', 'sponsorblock_categories', 'write_subtitles', 'enable_sponsorblock', 'sponsorblock_categories', 'write_subtitles',
'auto_subtitles', 'sub_langs') 'auto_subtitles', 'sub_langs')
@ -404,7 +408,7 @@ class SourceView(DetailView):
error_message = get_error_message(error) error_message = get_error_message(error)
setattr(error, 'error_message', error_message) setattr(error, 'error_message', error_message)
data['errors'].append(error) data['errors'].append(error)
data['media'] = Media.objects.filter(source=self.object).order_by('-published') data['media'] = Media.objects.filter(source=self.object).order_by('-published').defer('metadata')
return data return data
@ -435,14 +439,13 @@ class DeleteSourceView(DeleteView, FormMixin):
source = self.get_object() source = self.get_object()
for media in Media.objects.filter(source=source): for media in Media.objects.filter(source=source):
if media.media_file: if media.media_file:
# Delete the media file file_path = media.media_file.path
delete_file(media.media_file.path) matching_files = glob.glob(os.path.splitext(file_path)[0] + '.*')
# Delete thumbnail copy if it exists for file in matching_files:
delete_file(media.thumbpath) delete_file(file)
# Delete NFO file if it exists directory_path = source.directory_path
delete_file(media.nfopath) if os.path.exists(directory_path):
# Delete JSON file if it exists shutil.rmtree(directory_path, True)
delete_file(media.jsonpath)
return super().post(request, *args, **kwargs) return super().post(request, *args, **kwargs)
def get_success_url(self): def get_success_url(self):
@ -653,12 +656,13 @@ class MediaSkipView(FormView, SingleObjectMixin):
delete_task_by_media('sync.tasks.download_media', (str(self.object.pk),)) delete_task_by_media('sync.tasks.download_media', (str(self.object.pk),))
# If the media file exists on disk, delete it # If the media file exists on disk, delete it
if self.object.media_file_exists: if self.object.media_file_exists:
delete_file(self.object.media_file.path) # Delete all files which contains filename
self.object.media_file = None filepath = self.object.media_file.path
# If the media has an associated thumbnail copied, also delete it barefilepath, fileext = os.path.splitext(filepath)
delete_file(self.object.thumbpath) # Get all files that start with the bare file path
# If the media has an associated NFO file with it, also delete it all_related_files = glob.glob(f'{barefilepath}.*')
delete_file(self.object.nfopath) for file in all_related_files:
delete_file(file)
# Reset all download data # Reset all download data
self.object.metadata = None self.object.metadata = None
self.object.downloaded = False self.object.downloaded = False

View File

@ -1,5 +1,5 @@
''' '''
Wrapper for the youtube-dl library. Used so if there are any library interface Wrapper for the yt-dlp library. Used so if there are any library interface
updates we only need to udpate them in one place. updates we only need to udpate them in one place.
''' '''
@ -94,7 +94,7 @@ def get_media_info(url):
def download_media(url, media_format, extension, output_file, info_json, def download_media(url, media_format, extension, output_file, info_json,
sponsor_categories="all", sponsor_categories=None,
embed_thumbnail=False, embed_metadata=False, skip_sponsors=True, embed_thumbnail=False, embed_metadata=False, skip_sponsors=True,
write_subtitles=False, auto_subtitles=False, sub_langs='en'): write_subtitles=False, auto_subtitles=False, sub_langs='en'):
''' '''
@ -135,8 +135,8 @@ def download_media(url, media_format, extension, output_file, info_json,
f'{total_size_str} in {elapsed_str}') f'{total_size_str} in {elapsed_str}')
else: else:
log.warn(f'[youtube-dl] unknown event: {str(event)}') log.warn(f'[youtube-dl] unknown event: {str(event)}')
hook.download_progress = 0
hook.download_progress = 0
ytopts = { ytopts = {
'format': media_format, 'format': media_format,
'merge_output_format': extension, 'merge_output_format': extension,
@ -149,27 +149,23 @@ def download_media(url, media_format, extension, output_file, info_json,
'writeautomaticsub': auto_subtitles, 'writeautomaticsub': auto_subtitles,
'subtitleslangs': sub_langs.split(','), 'subtitleslangs': sub_langs.split(','),
} }
if not sponsor_categories:
sponsor_categories = []
sbopt = { sbopt = {
'key': 'SponsorBlock', 'key': 'SponsorBlock',
'categories': [sponsor_categories] 'categories': sponsor_categories
} }
ffmdopt = { ffmdopt = {
'key': 'FFmpegMetadata', 'key': 'FFmpegMetadata',
'add_chapters': True, 'add_chapters': embed_metadata,
'add_metadata': True 'add_metadata': embed_metadata
} }
opts = get_yt_opts() opts = get_yt_opts()
if embed_thumbnail: if embed_thumbnail:
ytopts['postprocessors'].append({'key': 'EmbedThumbnail'}) ytopts['postprocessors'].append({'key': 'EmbedThumbnail'})
if embed_metadata:
ffmdopt["add_metadata"] = True
if skip_sponsors: if skip_sponsors:
ytopts['postprocessors'].append(sbopt) ytopts['postprocessors'].append(sbopt)
ytopts['postprocessors'].append(ffmdopt) ytopts['postprocessors'].append(ffmdopt)
opts.update(ytopts) opts.update(ytopts)
with yt_dlp.YoutubeDL(opts) as y: with yt_dlp.YoutubeDL(opts) as y:

View File

@ -25,9 +25,6 @@ DEBUG = True if os.getenv('TUBESYNC_DEBUG', False) else False
FORCE_SCRIPT_NAME = os.getenv('DJANGO_FORCE_SCRIPT_NAME', DJANGO_URL_PREFIX) FORCE_SCRIPT_NAME = os.getenv('DJANGO_FORCE_SCRIPT_NAME', DJANGO_URL_PREFIX)
TIME_ZONE = os.getenv('TZ', 'UTC')
database_dict = {} database_dict = {}
database_connection_env = os.getenv('DATABASE_CONNECTION', '') database_connection_env = os.getenv('DATABASE_CONNECTION', '')
if database_connection_env: if database_connection_env:

View File

@ -1,3 +1,4 @@
import os
from pathlib import Path from pathlib import Path
@ -96,7 +97,7 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC' TIME_ZONE = os.getenv('TZ', 'UTC')
USE_I18N = True USE_I18N = True
USE_L10N = True USE_L10N = True
USE_TZ = True USE_TZ = True