diff --git a/tubesync/sync/forms.py b/tubesync/sync/forms.py index e09cc8d..adfe944 100644 --- a/tubesync/sync/forms.py +++ b/tubesync/sync/forms.py @@ -22,3 +22,8 @@ class ConfirmDeleteSourceForm(forms.Form): label=_('Also delete downloaded media'), required=False ) + + +class RedownloadMediaForm(forms.Form): + + pass diff --git a/tubesync/sync/models.py b/tubesync/sync/models.py index 195d324..27e116c 100644 --- a/tubesync/sync/models.py +++ b/tubesync/sync/models.py @@ -396,6 +396,20 @@ class Media(models.Model): Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'formats', } } + STATE_UNKNOWN = 'unknown' + STATE_SCHEDULED = 'scheduled' + STATE_DOWNLOADING = 'downloading' + STATE_DOWNLOADED = 'downloaded' + STATE_ERROR = 'error' + STATES = (STATE_UNKNOWN, STATE_SCHEDULED, STATE_DOWNLOADING, STATE_DOWNLOADED, + STATE_ERROR) + STATE_ICONS = { + STATE_UNKNOWN: '', + STATE_SCHEDULED: '', + STATE_DOWNLOADING: '', + STATE_DOWNLOADED: '', + STATE_ERROR: '', + } uuid = models.UUIDField( _('uuid'), @@ -649,14 +663,17 @@ class Media(models.Model): name = slugify(self.name.replace('&', 'and').replace('+', 'and'))[:50] key = self.key.strip() fmt = self.source.source_resolution.lower() - codecs = [] - vcodec = self.source.source_vcodec.lower() - acodec = self.source.source_acodec.lower() - if vcodec: - codecs.append(vcodec) - if acodec: - codecs.append(acodec) - codecs = '-'.join(codecs) + if self.source.is_audio(): + codecs = self.source.source_acodec.lower() + else: + codecs = [] + vcodec = self.source.source_vcodec.lower() + acodec = self.source.source_acodec.lower() + if vcodec: + codecs.append(vcodec) + if acodec: + codecs.append(acodec) + codecs = '-'.join(codecs) ext = self.source.extension return f'{datestr}_{source_name}_{name}_{key}-{fmt}-{codecs}.{ext}' @@ -664,6 +681,34 @@ class Media(models.Model): def filepath(self): return self.source.directory_path / self.filename + @property + def thumb_file_exists(self): + if not self.thumb: + return False + return os.path.exists(self.thumb.path) + + @property + def media_file_exists(self): + if not self.media_file: + return False + return os.path.exists(self.media_file.path) + + def get_download_state(self, task=None): + if self.downloaded: + return self.STATE_DOWNLOADED + if task: + if task.locked_by_pid_running(): + return self.STATE_DOWNLOADING + elif task.has_error(): + return self.STATE_ERROR + else: + return self.STATE_SCHEDULED + return self.STATE_UNKNOWN + + def get_download_state_icon(self, task=None): + state = self.get_download_state(task) + return self.STATE_ICONS.get(state, self.STATE_ICONS[self.STATE_UNKNOWN]) + def download_media(self): format_str = self.get_format_str() if not format_str: diff --git a/tubesync/sync/signals.py b/tubesync/sync/signals.py index 2bce9e3..1b7e4e7 100644 --- a/tubesync/sync/signals.py +++ b/tubesync/sync/signals.py @@ -29,6 +29,7 @@ def source_pre_save(sender, instance, **kwargs): str(instance.pk), repeat=instance.index_schedule, queue=str(instance.pk), + priority=5, verbose_name=verbose_name.format(instance.name) ) @@ -46,6 +47,7 @@ def source_post_save(sender, instance, created, **kwargs): str(instance.pk), repeat=instance.index_schedule, queue=str(instance.pk), + priority=5, verbose_name=verbose_name.format(instance.name) ) # Trigger the post_save signal for each media item linked to this source as various @@ -82,9 +84,18 @@ def task_task_failed(sender, task_id, completed_task, **kwargs): @receiver(post_save, sender=Media) def media_post_save(sender, instance, created, **kwargs): - # Triggered after media is saved - if created: - # If the media is newly created start a task to download its thumbnail + # Triggered after media is saved, Recalculate the "can_download" flag, this may + # need to change if the source specifications have been changed + if instance.get_format_str(): + if not instance.can_download: + instance.can_download = True + instance.save() + else: + if instance.can_download: + instance.can_download = True + instance.save() + # If the media is missing a thumbnail schedule it to be downloaded + if not instance.thumb: thumbnail_url = instance.thumbnail if thumbnail_url: log.info(f'Scheduling task to download thumbnail for: {instance.name} ' @@ -94,18 +105,9 @@ def media_post_save(sender, instance, created, **kwargs): str(instance.pk), thumbnail_url, queue=str(instance.source.pk), + priority=10, verbose_name=verbose_name.format(instance.name) ) - # Recalculate the "can_download" flag, this may need to change if the source - # specifications have been changed - if instance.get_format_str(): - if not instance.can_download: - instance.can_download = True - instance.save() - else: - if instance.can_download: - instance.can_download = True - instance.save() # If the media has not yet been downloaded schedule it to be downloaded if not instance.downloaded: delete_task_by_media('sync.tasks.download_media', (str(instance.pk),)) @@ -113,6 +115,7 @@ def media_post_save(sender, instance, created, **kwargs): download_media( str(instance.pk), queue=str(instance.source.pk), + priority=15, verbose_name=verbose_name.format(instance.name) ) diff --git a/tubesync/sync/tasks.py b/tubesync/sync/tasks.py index 2c3d295..e851a11 100644 --- a/tubesync/sync/tasks.py +++ b/tubesync/sync/tasks.py @@ -103,6 +103,14 @@ def get_source_completed_tasks(source_id, only_errors=False): return CompletedTask.objects.filter(**q).order_by('-failed_at') +def get_media_download_task(media_id): + try: + return Task.objects.get_task('sync.tasks.download_media', + args=(str(media_id),))[0] + except IndexError: + return False + + def delete_task_by_source(task_name, source_id): return Task.objects.filter(task_name=task_name, queue=str(source_id)).delete() diff --git a/tubesync/sync/templates/sync/media-item.html b/tubesync/sync/templates/sync/media-item.html index e78f657..836a6e9 100644 --- a/tubesync/sync/templates/sync/media-item.html +++ b/tubesync/sync/templates/sync/media-item.html @@ -5,13 +5,14 @@ {% block content %}
-

Media {{ media.key }}

+

Media {{ media.key }} {{ download_state_icon|safe }}

{% if media.title %}

{{ media.title }}

{% endif %}

{{ media.url }}

Downloading to: {{ media.source.directory_path }}

{% if not media.can_download %}{% include 'errorbox.html' with message='Media cannot be downloaded because it has no formats which match the source requirements.' %}{% endif %} +{% include 'infobox.html' with message=message %}
@@ -26,6 +27,19 @@
+{% if task %} +
+
+
+ + {{ task }}
+ {% if task.instance.index_schedule %}Scheduled to run {{ task.instance.get_index_schedule_display|lower }}.
{% endif %} + Task will run {% if task.run_now %}immediately{% else %}at {{ task.run_at|date:'Y-m-d H:i:s' }}{% endif %} +
+
+
+
+{% endif %}
@@ -106,4 +120,11 @@
+{% if media.downloaded %} +
+
+ Delete and redownload media +
+
+{% endif %} {% endblock %} diff --git a/tubesync/sync/templates/sync/media-redownload.html b/tubesync/sync/templates/sync/media-redownload.html new file mode 100644 index 0000000..6cc2625 --- /dev/null +++ b/tubesync/sync/templates/sync/media-redownload.html @@ -0,0 +1,29 @@ +{% extends 'base.html' %} + +{% block headtitle %}Redownload media - {{ media }}{% endblock %} + +{% block content %} +
+
+

Redownload media {{ media }}

+

+ You can delete the downloaded file for your media {{ media }} and + schedule it to be redownloaded. You might want to use this if you moved the original + file on disk and want to download it again, or, if you changed your source settings + such as changed the desired resolution and want to redownload the media in a different + format. +

+
+
+
+
+ {% csrf_token %} + {% include 'simpleform.html' with form=form %} +
+
+ +
+
+
+
+{% endblock %} diff --git a/tubesync/sync/templates/sync/media.html b/tubesync/sync/templates/sync/media.html index 7f116e0..2df760c 100644 --- a/tubesync/sync/templates/sync/media.html +++ b/tubesync/sync/templates/sync/media.html @@ -18,7 +18,7 @@ {{ m.source }}
{{ m.name }}
- {% if m.can_download %}{% if m.downloaded %}{% else %}{% endif %} {{ m.published|date:'Y-m-d' }}{% else %} No matching formats{% endif %} + {% if m.can_download %}{% if m.downloaded %}{% else %}{% endif %} {{ m.published|date:'Y-m-d' }}{% else %} No matching formats{% endif %}
diff --git a/tubesync/sync/templates/sync/tasks.html b/tubesync/sync/templates/sync/tasks.html index 9ab66cd..79bb4ba 100644 --- a/tubesync/sync/templates/sync/tasks.html +++ b/tubesync/sync/templates/sync/tasks.html @@ -66,7 +66,7 @@ {{ task }}
{% if task.instance.index_schedule %}Scheduled to run {{ task.instance.get_index_schedule_display|lower }}.
{% endif %} - Task will next run at {{ task.run_at|date:'Y-m-d H:i:s' }} + Task will run {% if task.run_now %}immediately{% else %}at {{ task.run_at|date:'Y-m-d H:i:s' }}{% endif %}
{% empty %} There are no scheduled tasks. diff --git a/tubesync/sync/urls.py b/tubesync/sync/urls.py index 923edd8..120f94b 100644 --- a/tubesync/sync/urls.py +++ b/tubesync/sync/urls.py @@ -1,7 +1,8 @@ from django.urls import path from .views import (DashboardView, SourcesView, ValidateSourceView, AddSourceView, SourceView, UpdateSourceView, DeleteSourceView, MediaView, - MediaThumbView, MediaItemView, TasksView, CompletedTasksView) + MediaThumbView, MediaItemView, MediaRedownloadView, TasksView, + CompletedTasksView) app_name = 'sync' @@ -55,6 +56,10 @@ urlpatterns = [ MediaItemView.as_view(), name='media-item'), + path('media-redownload/', + MediaRedownloadView.as_view(), + name='redownload-media'), + # Task URLs path('tasks', diff --git a/tubesync/sync/views.py b/tubesync/sync/views.py index f12b16a..2b227bd 100644 --- a/tubesync/sync/views.py +++ b/tubesync/sync/views.py @@ -4,6 +4,7 @@ from django.http import Http404 from django.views.generic import TemplateView, ListView, DetailView from django.views.generic.edit import (FormView, FormMixin, CreateView, UpdateView, DeleteView) +from django.views.generic.detail import SingleObjectMixin from django.http import HttpResponse from django.urls import reverse_lazy from django.db.models import Count @@ -14,9 +15,11 @@ from django.utils.translation import gettext_lazy as _ from common.utils import append_uri_params from background_task.models import Task, CompletedTask from .models import Source, Media -from .forms import ValidateSourceForm, ConfirmDeleteSourceForm +from .forms import ValidateSourceForm, ConfirmDeleteSourceForm, RedownloadMediaForm from .utils import validate_url, delete_file -from .tasks import map_task_to_instance, get_error_message, get_source_completed_tasks +from .tasks import (map_task_to_instance, get_error_message, + get_source_completed_tasks, get_media_download_task, + delete_task_by_media) from . import signals from . import youtube @@ -295,6 +298,7 @@ class DeleteSourceView(DeleteView, FormMixin): delete_media_val = request.POST.get('delete_media', False) delete_media = True if delete_media_val is not False else False if delete_media: + source = self.get_object() for media in Media.objects.filter(source=source): if media.media_file: delete_file(media.media_file.name) @@ -378,12 +382,29 @@ class MediaItemView(DetailView): template_name = 'sync/media-item.html' model = Media + messages = { + 'redownloading': _('Media file has been deleted and scheduled to redownload'), + } + + def __init__(self, *args, **kwargs): + self.message = None + super().__init__(*args, **kwargs) + + def dispatch(self, request, *args, **kwargs): + message_key = request.GET.get('message', '') + self.message = self.messages.get(message_key, '') + return super().dispatch(request, *args, **kwargs) def get_context_data(self, *args, **kwargs): data = super().get_context_data(*args, **kwargs) + data['message'] = self.message combined_exact, combined_format = self.object.get_best_combined_format() audio_exact, audio_format = self.object.get_best_audio_format() video_exact, video_format = self.object.get_best_video_format() + task = get_media_download_task(self.object.pk) + data['task'] = task + data['download_state'] = self.object.get_download_state(task) + data['download_state_icon'] = self.object.get_download_state_icon(task) data['combined_exact'] = combined_exact data['combined_format'] = combined_format data['audio_exact'] = audio_exact @@ -394,6 +415,48 @@ class MediaItemView(DetailView): return data +class MediaRedownloadView(FormView, SingleObjectMixin): + + template_name = 'sync/media-redownload.html' + form_class = RedownloadMediaForm + model = Media + + def __init__(self, *args, **kwargs): + self.object = None + super().__init__(*args, **kwargs) + + def dispatch(self, request, *args, **kwargs): + self.object = self.get_object() + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + # Delete any active download tasks for the media + delete_task_by_media('sync.tasks.download_media', (str(self.object.pk),)) + # If the thumbnail file exists on disk, delete it + if self.object.thumb_file_exists: + delete_file(self.object.thumb.path) + self.object.thumb = None + # If the media file exists on disk, delete it + if self.object.media_file_exists: + delete_file(self.object.media_file.path) + self.object.media_file = None + # Reset all download data + self.object.downloaded = False + self.object.downloaded_audio_codec = None + self.object.downloaded_video_codec = None + self.object.downloaded_container = None + self.object.downloaded_fps = None + self.object.downloaded_hdr = False + self.object.downloaded_filesize = None + # Saving here will trigger the post_create signals to schedule new tasks + self.object.save() + return super().form_valid(form) + + def get_success_url(self): + url = reverse_lazy('sync:media-item', kwargs={'pk': self.object.pk}) + return append_uri_params(url, {'message': 'redownloading'}) + + class TasksView(ListView): ''' A list of tasks queued to be completed. This is, for example, scraping for new @@ -420,6 +483,7 @@ class TasksView(ListView): continue setattr(task, 'instance', obj) setattr(task, 'url', url) + setattr(task, 'run_now', task.run_at < now) if task.locked_by_pid_running(): data['running'].append(task) elif task.has_error(): diff --git a/tubesync/tubesync/settings.py b/tubesync/tubesync/settings.py index 97560ee..2120836 100644 --- a/tubesync/tubesync/settings.py +++ b/tubesync/tubesync/settings.py @@ -117,8 +117,8 @@ MAX_ATTEMPTS = 10 # Number of times tasks will be retr MAX_RUN_TIME = 1800 # Maximum amount of time in seconds a task can run BACKGROUND_TASK_RUN_ASYNC = True # Run tasks async in the background BACKGROUND_TASK_ASYNC_THREADS = 2 # Number of async tasks to run at once -BACKGROUND_TASK_PRIORITY_ORDERING = 'DESC' # Process high priority tasks first -COMPLETED_TASKS_DAYS_TO_KEEP = 30 # Number of days to keep completed tasks +BACKGROUND_TASK_PRIORITY_ORDERING = 'ASC' # Use 'niceness' task priority ordering +COMPLETED_TASKS_DAYS_TO_KEEP = 30 # Number of days to keep completed tasks SOURCES_PER_PAGE = 36