Merge pull request #1073 from tcely/patch-4

Targeted indexing schedule
This commit is contained in:
meeb 2025-05-29 14:14:43 +10:00 committed by GitHub
commit 4d160ee673
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 157 additions and 29 deletions

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

@ -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

@ -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

@ -8,7 +8,7 @@ CONFIG_BASE_DIR = BASE_DIR
DOWNLOADS_BASE_DIR = BASE_DIR DOWNLOADS_BASE_DIR = BASE_DIR
VERSION = '0.15.4' VERSION = '0.15.5'
SECRET_KEY = '' SECRET_KEY = ''
DEBUG = False DEBUG = False
ALLOWED_HOSTS = [] ALLOWED_HOSTS = []