Merge branch 'main' into patch-6

This commit is contained in:
tcely 2025-05-29 01:41:05 -04:00 committed by GitHub
commit 9fd8c7ce69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 164 additions and 56 deletions

View File

@ -265,15 +265,7 @@ and less common features:
# Warnings # Warnings
### 1. Automated file renaming ### 1. Index frequency
> [!IMPORTANT]
> Currently, file renaming is not enabled by default.
> Enabling this feature by default is planned in an upcoming release, after `2025-006-01`.
>
> To prevent your installation from scheduling media file renaming tasks,
> you must set [`TUBESYNC_RENAME_ALL_SOURCES=False`](#advanced-configuration) in the environment variables or `RENAME_ALL_SOURCES = False` in [`settings.py`](../1fc0462c11741621350053144ab19cba5f266cb2/tubesync/tubesync/settings.py#L183).
### 2. Index frequency
It's a good idea to add sources with as long of an index frequency as possible. This is It's a good idea to add sources with as long of an index frequency as possible. This is
the duration between indexes of the source. An index is when TubeSync checks to see the duration between indexes of the source. An index is when TubeSync checks to see
@ -281,7 +273,7 @@ what videos available on a channel or playlist to find new media. Try and keep t
long as possible, up to 24 hours. long as possible, up to 24 hours.
### 3. Indexing massive channels ### 2. Indexing massive channels
If you add a massive channel (one with several thousand videos) to TubeSync and choose "index If you add a massive channel (one with several thousand videos) to TubeSync and choose "index
every hour" or a similarly short interval; it's entirely possible that your TubeSync install may every hour" or a similarly short interval; it's entirely possible that your TubeSync install may

View File

@ -167,6 +167,28 @@ class TaskQueue(models.TextChoices):
NET = 'network', _('Networking') NET = 'network', _('Networking')
class WeekDay(models.IntegerChoices):
MON = 0, _('Monday')
TUE = 1, _('Tuesday')
WED = 2, _('Wednesday')
THU = 3, _('Thursday')
FRI = 4, _('Friday')
SAT = 5, _('Saturday')
SUN = 6, _('Sunday')
@classmethod
def get(cls, wdn, /):
return cls[cls.names[wdn]]
@classmethod
def _from_iso(cls, wdn, /):
return cls[cls.names[wdn - 1]]
@classmethod
def _to_iso(cls, choice, /):
return 1 + choice.value
class YouTube_SourceType(models.TextChoices): class YouTube_SourceType(models.TextChoices):
CHANNEL = 'c', _('YouTube channel') CHANNEL = 'c', _('YouTube channel')
CHANNEL_ID = 'i', _('YouTube channel by ID') CHANNEL_ID = 'i', _('YouTube channel by ID')

View File

@ -2,6 +2,8 @@
from django import forms, VERSION as DJANGO_VERSION from django import forms, VERSION as DJANGO_VERSION
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import Source
if DJANGO_VERSION[0:3] < (5, 0, 0): if DJANGO_VERSION[0:3] < (5, 0, 0):
_assume_scheme = dict() _assume_scheme = dict()
@ -9,6 +11,24 @@ else:
# Silence RemovedInDjango60Warning # Silence RemovedInDjango60Warning
_assume_scheme = dict(assume_scheme='http') _assume_scheme = dict(assume_scheme='http')
SourceForm = forms.modelform_factory(
Source,
# manual ordering
fields = (
'source_type', 'key', 'name', 'directory', 'filter_text', 'filter_text_invert', 'filter_seconds', 'filter_seconds_min',
'media_format', 'target_schedule', 'index_schedule', 'index_videos', 'index_streams', 'download_media',
'download_cap', 'delete_old_media', 'days_to_keep', 'source_resolution', 'source_vcodec', 'source_acodec',
'prefer_60fps', 'prefer_hdr', 'fallback', 'delete_removed_media', 'delete_files_on_disk', 'copy_channel_images',
'copy_thumbnails', 'write_nfo', 'write_json', 'embed_metadata', 'embed_thumbnail',
'enable_sponsorblock', 'sponsorblock_categories', 'write_subtitles', 'auto_subtitles', 'sub_langs',
),
widgets = {
'target_schedule': forms.DateTimeInput(
attrs={'type': 'datetime-local'},
),
},
)
class ValidateSourceForm(forms.Form): class ValidateSourceForm(forms.Form):
source_type = forms.CharField( source_type = forms.CharField(

View File

@ -29,7 +29,7 @@ class Command(BaseCommand):
index_source_task( index_source_task(
str(source.pk), str(source.pk),
repeat=source.index_schedule, repeat=source.index_schedule,
schedule=source.index_schedule, schedule=source.task_run_at_dt,
verbose_name=verbose_name.format(source.name), verbose_name=verbose_name.format(source.name),
) )
# This also chains down to call each Media objects .save() as well # This also chains down to call each Media objects .save() as well

View File

@ -0,0 +1,38 @@
# Generated by Django 5.2.1 on 2025-05-26 04:43
import django.utils.timezone
import sync.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sync', '0033_alter_mediaserver_options_alter_source_source_acodec_and_more'),
]
operations = [
migrations.AddField(
model_name='source',
name='target_schedule',
field=models.DateTimeField(
blank=True, db_index=True, default=django.utils.timezone.now,
help_text='Date and time when the task to index the source should begin',
verbose_name='target schedule',
),
),
migrations.AlterField(
model_name='source',
name='sponsorblock_categories',
field=sync.fields.CommaSepChoiceField(
all_choice='all', all_label='(All Categories)', allow_all=True, default='all',
help_text='Select the SponsorBlock categories that you wish to be removed from downloaded videos.',
max_length=128, possible_choices=[
('sponsor', 'Sponsor'), ('intro', 'Intermission/Intro Animation'), ('outro', 'Endcards/Credits'),
('selfpromo', 'Unpaid/Self Promotion'), ('preview', 'Preview/Recap'), ('filler', 'Filler Tangent'),
('interaction', 'Interaction Reminder'), ('music_offtopic', 'Non-Music Section'),
],
verbose_name='removed categories',
),
),
]

View File

@ -134,9 +134,9 @@ class Metadata(db.models.Model):
('release_timestamp', 'timestamp',), ('release_timestamp', 'timestamp',),
arg_dict=data, arg_dict=data,
) )
) or self.media.published ) or self.media.published or self.retrieved
except AssertionError: except AssertionError:
self.published = self.media.published self.published = self.media.published or self.retrieved
self.value = data.copy() # try not to have side-effects for the caller self.value = data.copy() # try not to have side-effects for the caller
formats_key = self.media.get_metadata_field('formats') formats_key = self.media.get_metadata_field('formats')

View File

@ -31,16 +31,6 @@ class Source(db.models.Model):
or a YouTube playlist. or a YouTube playlist.
''' '''
sponsorblock_categories = CommaSepChoiceField(
_(''),
max_length=128,
possible_choices=SponsorBlock_Category.choices,
all_choice='all',
allow_all=True,
all_label='(All Categories)',
default='all',
help_text=_('Select the SponsorBlock categories that you wish to be removed from downloaded videos.'),
)
embed_metadata = db.models.BooleanField( embed_metadata = db.models.BooleanField(
_('embed metadata'), _('embed metadata'),
default=False, default=False,
@ -51,11 +41,6 @@ class Source(db.models.Model):
default=False, default=False,
help_text=_('Embed thumbnail into the file'), help_text=_('Embed thumbnail into the file'),
) )
enable_sponsorblock = db.models.BooleanField(
_('enable sponsorblock'),
default=True,
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 = _srctype_dict('<i class="fab fa-youtube"></i>') ICONS = _srctype_dict('<i class="fab fa-youtube"></i>')
@ -141,6 +126,13 @@ class Source(db.models.Model):
default=settings.MEDIA_FORMATSTR_DEFAULT, default=settings.MEDIA_FORMATSTR_DEFAULT,
help_text=_('File format to use for saving files, detailed options at bottom of page.'), help_text=_('File format to use for saving files, detailed options at bottom of page.'),
) )
target_schedule = db.models.DateTimeField(
_('target schedule'),
blank=True,
db_index=True,
default=timezone.now,
help_text=_('Date and time when the task to index the source should begin'),
)
index_schedule = db.models.IntegerField( index_schedule = db.models.IntegerField(
_('index schedule'), _('index schedule'),
choices=IndexSchedule.choices, choices=IndexSchedule.choices,
@ -310,6 +302,21 @@ class Source(db.models.Model):
), ),
], ],
) )
enable_sponsorblock = db.models.BooleanField(
_('enable sponsorblock'),
default=True,
help_text=_('Use SponsorBlock?'),
)
sponsorblock_categories = CommaSepChoiceField(
_('removed categories'),
max_length=128,
possible_choices=SponsorBlock_Category.choices,
all_choice='all',
allow_all=True,
all_label='(All Categories)',
default='all',
help_text=_('Select the SponsorBlock categories that you wish to be removed from downloaded videos.'),
)
def __str__(self): def __str__(self):
return self.name return self.name
@ -376,6 +383,39 @@ class Source(db.models.Model):
else: else:
return False return False
@property
def task_run_at_dt(self):
now = timezone.now()
when = now.replace(minute=0, second=0, microsecond=0)
def advance_hour(arg_dt, target_hour, /):
delta_hours = ((24 + target_hour) - arg_dt.hour) % 24
return arg_dt + timezone.timedelta(hours=delta_hours)
def advance_day(arg_dt, target_weekday, /):
delta_days = ((7 + target_weekday) - arg_dt.weekday) % 7
return arg_dt + timezone.timedelta(days=delta_days)
if self.target_schedule is None:
self.target_schedule = when
if Val(IndexSchedule.EVERY_24_HOURS) > self.index_schedule:
self.target_schedule = now + timezone.timedelta(
seconds=1+self.index_schedule,
)
elif Val(IndexSchedule.EVERY_7_DAYS) > self.index_schedule:
self.target_schedule = advance_hour(
when.replace(hour=1+when.hour),
self.target_schedule.hour,
)
if now < self.target_schedule:
return self.target_schedule
when = advance_hour(when, self.target_schedule.hour)
when = advance_day(when, self.target_schedule.weekday)
self.target_schedule = when
return when
@property @property
def extension(self): def extension(self):
''' '''

View File

@ -95,7 +95,7 @@ def source_pre_save(sender, instance, **kwargs):
index_source_task( index_source_task(
str(instance.pk), str(instance.pk),
repeat=instance.index_schedule, repeat=instance.index_schedule,
schedule=instance.index_schedule, schedule=instance.task_run_at_dt,
verbose_name=verbose_name.format(instance.name), verbose_name=verbose_name.format(instance.name),
) )

View File

@ -319,6 +319,8 @@ def index_source_task(source_id):
# An inactive Source would return an empty list for videos anyway # An inactive Source would return an empty list for videos anyway
if not source.is_active: if not source.is_active:
return return
# update the target schedule column
source.task_run_at_dt
# Reset any errors # Reset any errors
# TODO: determine if this affects anything # TODO: determine if this affects anything
source.has_failed = False source.has_failed = False

View File

@ -99,18 +99,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="row">
<div class="col s12">
<h2 class="truncate">Warnings</h2>
<div class="collection-item">
An upcoming release, after <b>2025-006-01</b>, will introduce automated file renaming.<br>
To prevent this change from taking effect, you can set an environment variable before that date.<br>
See the <a href="https://github.com/meeb/tubesync#warnings" rel="external noreferrer">GitHub README</a>
for more details or ask questions using
issue <a href="https://github.com/meeb/tubesync/issues/785" rel="external noreferrer">#785</a>.<br>
</div>
</div>
</div>
<div class="row"> <div class="row">
<div class="col s12"> <div class="col s12">
<h2 class="truncate">Runtime information</h2> <h2 class="truncate">Runtime information</h2>

View File

@ -93,6 +93,10 @@
<td class="hide-on-small-only">Last crawl</td> <td class="hide-on-small-only">Last crawl</td>
<td><span class="hide-on-med-and-up">Last crawl<br></span><strong>{% if source.last_crawl %}{{ source.last_crawl|date:'Y-m-d H:i:s' }}{% else %}Never{% endif %}</strong></td> <td><span class="hide-on-med-and-up">Last crawl<br></span><strong>{% if source.last_crawl %}{{ source.last_crawl|date:'Y-m-d H:i:s' }}{% else %}Never{% endif %}</strong></td>
</tr> </tr>
<tr title="When the source should be next checked for available media">
<td class="hide-on-small-only">Target schedule</td>
<td><span class="hide-on-med-and-up">Target schedule<br></span><strong>{% if source.target_schedule %}{{ source.target_schedule|date:'l, h:00 A (c)' }}{% else %}Not set{% endif %}</strong></td>
</tr>
<tr title="Quality and type of media the source will attempt to sync"> <tr title="Quality and type of media the source will attempt to sync">
<td class="hide-on-small-only">Source resolution</td> <td class="hide-on-small-only">Source resolution</td>
<td><span class="hide-on-med-and-up">Source resolution<br></span><strong>{{ source.get_source_resolution_display }}</strong></td> <td><span class="hide-on-med-and-up">Source resolution<br></span><strong>{{ source.get_source_resolution_display }}</strong></td>

View File

@ -25,7 +25,7 @@ from background_task.models import Task, CompletedTask
from .models import Source, Media, MediaServer from .models import Source, Media, MediaServer
from .forms import (ValidateSourceForm, ConfirmDeleteSourceForm, RedownloadMediaForm, from .forms import (ValidateSourceForm, ConfirmDeleteSourceForm, RedownloadMediaForm,
SkipMediaForm, EnableMediaForm, ResetTasksForm, ScheduleTaskForm, SkipMediaForm, EnableMediaForm, ResetTasksForm, ScheduleTaskForm,
ConfirmDeleteMediaServerForm) ConfirmDeleteMediaServerForm, SourceForm)
from .utils import validate_url, delete_file, multi_key_sort, mkdir_p from .utils import validate_url, delete_file, multi_key_sort, mkdir_p
from .tasks import (map_task_to_instance, get_error_message, from .tasks import (map_task_to_instance, get_error_message,
get_source_completed_tasks, get_media_download_task, get_source_completed_tasks, get_media_download_task,
@ -276,15 +276,7 @@ class ValidateSourceView(FormView):
class EditSourceMixin: class EditSourceMixin:
model = Source model = Source
# manual ordering form_class = SourceForm
fields = ('source_type', 'key', 'name', 'directory', 'filter_text', 'filter_text_invert', 'filter_seconds', 'filter_seconds_min',
'media_format', 'index_schedule', 'index_videos', 'index_streams', 'download_media', 'download_cap', 'delete_old_media',
'days_to_keep', 'source_resolution', 'source_vcodec', 'source_acodec',
'prefer_60fps', 'prefer_hdr', 'fallback',
'delete_removed_media', 'delete_files_on_disk', 'copy_channel_images',
'copy_thumbnails', 'write_nfo', 'write_json', 'embed_metadata', 'embed_thumbnail',
'enable_sponsorblock', 'sponsorblock_categories', 'write_subtitles',
'auto_subtitles', 'sub_langs')
errors = { errors = {
'invalid_media_format': _('Invalid media format, the media format contains ' 'invalid_media_format': _('Invalid media format, the media format contains '
'errors or is empty. Check the table at the end of ' 'errors or is empty. Check the table at the end of '
@ -357,6 +349,9 @@ class AddSourceView(EditSourceMixin, CreateView):
def get_initial(self): def get_initial(self):
initial = super().get_initial() initial = super().get_initial()
initial['target_schedule'] = timezone.now().replace(
second=0, microsecond=0,
)
for k, v in self.prepopulated_data.items(): for k, v in self.prepopulated_data.items():
initial[k] = v initial[k] = v
return initial return initial
@ -403,6 +398,12 @@ class UpdateSourceView(EditSourceMixin, UpdateView):
template_name = 'sync/source-update.html' template_name = 'sync/source-update.html'
def get_initial(self):
initial = super().get_initial()
when = getattr(self.object, 'target_schedule') or timezone.now()
initial['target_schedule'] = when.replace(second=0, microsecond=0)
return initial
def get_success_url(self): def get_success_url(self):
url = reverse_lazy('sync:source', kwargs={'pk': self.object.pk}) url = reverse_lazy('sync:source', kwargs={'pk': self.object.pk})
return append_uri_params(url, {'message': 'source-updated'}) return append_uri_params(url, {'message': 'source-updated'})
@ -1003,6 +1004,7 @@ class ResetTasks(FormView):
index_source_task( index_source_task(
str(source.pk), str(source.pk),
repeat=source.index_schedule, repeat=source.index_schedule,
schedule=source.task_run_at_dt,
verbose_name=verbose_name.format(source.name) verbose_name=verbose_name.format(source.name)
) )
# This also chains down to call each Media objects .save() as well # This also chains down to call each Media objects .save() as well

View File

@ -108,11 +108,11 @@ SHRINK_OLD_MEDIA_METADATA = ( 'true' == SHRINK_OLD_MEDIA_METADATA_STR.strip().lo
# TUBESYNC_RENAME_ALL_SOURCES: True or False # TUBESYNC_RENAME_ALL_SOURCES: True or False
RENAME_ALL_SOURCES_STR = getenv('TUBESYNC_RENAME_ALL_SOURCES', False) RENAME_ALL_SOURCES_STR = getenv('TUBESYNC_RENAME_ALL_SOURCES', True)
RENAME_ALL_SOURCES = ( 'true' == RENAME_ALL_SOURCES_STR.strip().lower() ) RENAME_ALL_SOURCES = ( 'true' == RENAME_ALL_SOURCES_STR.strip().lower() )
# TUBESYNC_RENAME_SOURCES: A comma-separated list of Source directories # TUBESYNC_RENAME_SOURCES: A comma-separated list of Source directories
RENAME_SOURCES_STR = getenv('TUBESYNC_RENAME_SOURCES') RENAME_SOURCES_STR = getenv('TUBESYNC_RENAME_SOURCES')
RENAME_SOURCES = RENAME_SOURCES_STR.split(',') if RENAME_SOURCES_STR else None RENAME_SOURCES = RENAME_SOURCES_STR.split(',') if RENAME_SOURCES_STR else list()
VIDEO_HEIGHT_CUTOFF = getenv("TUBESYNC_VIDEO_HEIGHT_CUTOFF", 240, integer=True) VIDEO_HEIGHT_CUTOFF = getenv("TUBESYNC_VIDEO_HEIGHT_CUTOFF", 240, integer=True)

View File

@ -199,8 +199,8 @@ COOKIES_FILE = CONFIG_BASE_DIR / 'cookies.txt'
MEDIA_FORMATSTR_DEFAULT = '{yyyy_mm_dd}_{source}_{title}_{key}_{format}.{ext}' MEDIA_FORMATSTR_DEFAULT = '{yyyy_mm_dd}_{source}_{title}_{key}_{format}.{ext}'
RENAME_ALL_SOURCES = False RENAME_ALL_SOURCES = True
RENAME_SOURCES = None RENAME_SOURCES = list()
# WARNING WARNING WARNING # WARNING WARNING WARNING