task management and task runtime flow
This commit is contained in:
parent
f6d00b47eb
commit
647f7162cc
|
@ -0,0 +1,6 @@
|
||||||
|
class NoMediaException(Exception):
|
||||||
|
'''
|
||||||
|
Raised when a source returns no media to be indexed. Could be an invalid
|
||||||
|
playlist name or similar, or the upstream source returned an error.
|
||||||
|
'''
|
||||||
|
pass
|
|
@ -2,9 +2,9 @@ $colour-white: #ffffff;
|
||||||
$colour-black: #000000;
|
$colour-black: #000000;
|
||||||
$colour-near-black: #011627;
|
$colour-near-black: #011627;
|
||||||
$colour-near-white: #fdfffc;
|
$colour-near-white: #fdfffc;
|
||||||
$colour-light-blue: #2e8ac4;
|
$colour-light-blue: #1e5c83;
|
||||||
$colour-red: #e71d36;
|
$colour-red: #e71d36;
|
||||||
$colour-orange: #ef9912;
|
$colour-orange: #ff9c00;
|
||||||
|
|
||||||
$background-colour: $colour-near-white;
|
$background-colour: $colour-near-white;
|
||||||
$text-colour: $colour-near-black;
|
$text-colour: $colour-near-black;
|
||||||
|
@ -37,7 +37,7 @@ $form-help-text-colour: $colour-light-blue;
|
||||||
$form-delete-button-background-colour: $colour-red;
|
$form-delete-button-background-colour: $colour-red;
|
||||||
|
|
||||||
$collection-no-items-text-colour: $colour-near-black;
|
$collection-no-items-text-colour: $colour-near-black;
|
||||||
$collection-text-colour: $colour-near-black;
|
$collection-text-colour: $colour-light-blue;
|
||||||
$collection-background-hover-colour: $colour-orange;
|
$collection-background-hover-colour: $colour-orange;
|
||||||
$collection-text-hover-colour: $colour-near-white;
|
$collection-text-hover-colour: $colour-near-white;
|
||||||
|
|
||||||
|
@ -52,6 +52,9 @@ $box-error-text-colour: $colour-near-white;
|
||||||
$infobox-background-colour: $colour-near-black;
|
$infobox-background-colour: $colour-near-black;
|
||||||
$infobox-text-colour: $colour-near-white;
|
$infobox-text-colour: $colour-near-white;
|
||||||
|
|
||||||
|
$errorbox-background-colour: $colour-red;
|
||||||
|
$errorbox-text-colour: $colour-near-white;
|
||||||
|
|
||||||
$pagination-background-colour: $colour-near-white;
|
$pagination-background-colour: $colour-near-white;
|
||||||
$pagination-text-colour: $colour-near-black;
|
$pagination-text-colour: $colour-near-black;
|
||||||
$pagination-border-colour: $colour-light-blue;
|
$pagination-border-colour: $colour-light-blue;
|
||||||
|
@ -61,3 +64,5 @@ $pagination-border-hover-colour: $colour-light-blue;
|
||||||
$pagination-current-background-colour: $colour-orange;
|
$pagination-current-background-colour: $colour-orange;
|
||||||
$pagination-current-text-colour: $colour-near-white;
|
$pagination-current-text-colour: $colour-near-white;
|
||||||
$pagination-current-border-colour: $colour-orange;
|
$pagination-current-border-colour: $colour-orange;
|
||||||
|
|
||||||
|
$error-text-colour: $colour-red;
|
||||||
|
|
|
@ -75,6 +75,12 @@ main {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 2rem 0 0.5rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: $main-button-background-colour;
|
background-color: $main-button-background-colour;
|
||||||
|
@ -90,6 +96,7 @@ main {
|
||||||
.collection {
|
.collection {
|
||||||
margin: 0.5rem 0 0 0 !important;
|
margin: 0.5rem 0 0 0 !important;
|
||||||
.collection-item {
|
.collection-item {
|
||||||
|
transition: initial !important;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
a.collection-item {
|
a.collection-item {
|
||||||
|
@ -167,6 +174,15 @@ main {
|
||||||
color: $infobox-text-colour;
|
color: $infobox-text-colour;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.errorbox {
|
||||||
|
background-color: $errorbox-background-colour;
|
||||||
|
color: $errorbox-text-colour;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: $error-text-colour !important;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
|
|
|
@ -31,7 +31,7 @@
|
||||||
<li><a href="{% url 'sync:dashboard' %}"><i class="fas fa-fw fa-th-large"></i><span class="hide-on-med-and-down"> Dashboard</span></a></li>
|
<li><a href="{% url 'sync:dashboard' %}"><i class="fas fa-fw fa-th-large"></i><span class="hide-on-med-and-down"> Dashboard</span></a></li>
|
||||||
<li><a href="{% url 'sync:sources' %}"><i class="fas fa-fw fa-play"></i><span class="hide-on-med-and-down"> Sources</span></a></li>
|
<li><a href="{% url 'sync:sources' %}"><i class="fas fa-fw fa-play"></i><span class="hide-on-med-and-down"> Sources</span></a></li>
|
||||||
<li><a href="{% url 'sync:media' %}"><i class="fas fa-fw fa-film"></i><span class="hide-on-med-and-down"> Media</span></a></li>
|
<li><a href="{% url 'sync:media' %}"><i class="fas fa-fw fa-film"></i><span class="hide-on-med-and-down"> Media</span></a></li>
|
||||||
<li><a href="{% url 'sync:tasks' %}"><i class="fas fa-fw fa-clock"></i><span class="hide-on-med-and-down"> Tasks</span></a></li>
|
<li><a href="{% url 'sync:tasks' %}"><i class="far fa-fw fa-clock"></i><span class="hide-on-med-and-down"> Tasks</span></a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
{% if message %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s12">
|
||||||
|
<div class="card errorbox">
|
||||||
|
<div class="card-content">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i> {{ message|safe }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
|
@ -6,7 +6,7 @@ from .models import Source, Media
|
||||||
class SourceAdmin(admin.ModelAdmin):
|
class SourceAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
ordering = ('-created',)
|
ordering = ('-created',)
|
||||||
list_display = ('name',)
|
list_display = ('name', 'get_source_type_display', 'last_crawl', 'has_failed')
|
||||||
readonly_fields = ('uuid', 'created')
|
readonly_fields = ('uuid', 'created')
|
||||||
search_fields = ('uuid', 'key', 'name')
|
search_fields = ('uuid', 'key', 'name')
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 3.1.4 on 2020-12-07 07:31
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0013_auto_20201207_0439'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='has_errors',
|
||||||
|
field=models.BooleanField(default=False, help_text='Source has errors', verbose_name='has errors'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Generated by Django 3.1.4 on 2020-12-07 07:44
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0014_source_has_errors'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='source',
|
||||||
|
name='has_errors',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='has_failed',
|
||||||
|
field=models.BooleanField(default=False, help_text='Source has failed to index media', verbose_name='has failed'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -221,6 +221,11 @@ class Source(models.Model):
|
||||||
default=FALLBACK_NEXT_HD,
|
default=FALLBACK_NEXT_HD,
|
||||||
help_text=_('What do do when media in your source resolution and codecs is not available')
|
help_text=_('What do do when media in your source resolution and codecs is not available')
|
||||||
)
|
)
|
||||||
|
has_failed = models.BooleanField(
|
||||||
|
_('has failed'),
|
||||||
|
default=False,
|
||||||
|
help_text=_('Source has failed to index media')
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
@ -307,6 +312,8 @@ class Source(models.Model):
|
||||||
# Account for nested playlists, such as a channel of playlists of playlists
|
# Account for nested playlists, such as a channel of playlists of playlists
|
||||||
def _recurse_playlists(playlist):
|
def _recurse_playlists(playlist):
|
||||||
videos = []
|
videos = []
|
||||||
|
if not playlist:
|
||||||
|
return videos
|
||||||
entries = playlist.get('entries', [])
|
entries = playlist.get('entries', [])
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if not entry:
|
if not entry:
|
||||||
|
|
|
@ -1,47 +1,98 @@
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models.signals import 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
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from background_task.signals import task_failed
|
||||||
|
from background_task.models import Task
|
||||||
|
from common.logger import log
|
||||||
from .models import Source, Media
|
from .models import Source, Media
|
||||||
from .tasks import delete_index_source_task, index_source_task, download_media_thumbnail
|
from .tasks import (delete_index_source_task, index_source_task,
|
||||||
|
download_media_thumbnail, map_task_to_instance)
|
||||||
from .utils import delete_file
|
from .utils import delete_file
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(pre_save, sender=Source)
|
||||||
|
def source_pre_save(sender, instance, **kwargs):
|
||||||
|
# Triggered before a source is saved, if the schedule has been updated recreate
|
||||||
|
# its indexing task
|
||||||
|
try:
|
||||||
|
existing_source = Source.objects.get(pk=instance.pk)
|
||||||
|
except Source.DoesNotExist:
|
||||||
|
# Probably not possible?
|
||||||
|
return
|
||||||
|
if existing_source.index_schedule != instance.index_schedule:
|
||||||
|
# Indexing schedule has changed, recreate the indexing task
|
||||||
|
delete_index_source_task(str(instance.pk))
|
||||||
|
verbose_name = _('Index media from source "{}"')
|
||||||
|
index_source_task(
|
||||||
|
str(instance.pk),
|
||||||
|
repeat=instance.index_schedule,
|
||||||
|
verbose_name=verbose_name.format(instance.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Source)
|
@receiver(post_save, sender=Source)
|
||||||
def source_post_save(sender, instance, created, **kwargs):
|
def source_post_save(sender, instance, created, **kwargs):
|
||||||
# Triggered when a source is saved, delete any source tasks that might exist
|
# Triggered after a source is saved
|
||||||
delete_index_source_task(str(instance.pk))
|
if created:
|
||||||
# Create a new scheduled indexing task as the repeat schedule may have changed
|
# Create a new indexing task for newly created sources
|
||||||
index_source_task(str(instance.pk), repeat=instance.index_schedule)
|
delete_index_source_task(str(instance.pk))
|
||||||
|
log.info(f'Scheduling media indexing for source: {instance.name}')
|
||||||
|
verbose_name = _('Index media from source "{}"')
|
||||||
|
index_source_task(
|
||||||
|
str(instance.pk),
|
||||||
|
repeat=instance.index_schedule,
|
||||||
|
verbose_name=verbose_name.format(instance.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_delete, sender=Source)
|
@receiver(pre_delete, sender=Source)
|
||||||
def source_post_delete(sender, instance, **kwargs):
|
def source_post_delete(sender, instance, **kwargs):
|
||||||
# Triggered just before a source is deleted, delete all media objects to trigger
|
# Triggered before a source is deleted, delete all media objects to trigger
|
||||||
# the Media models post_delete signal
|
# the Media models post_delete signal
|
||||||
for media in Media.objects.filter(source=instance):
|
for media in Media.objects.filter(source=instance):
|
||||||
|
log.info(f'Deleting media for source: {instance.name} item: {media.name}')
|
||||||
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 when a source is deleted
|
# Triggered after a source is deleted
|
||||||
|
log.info(f'Deleting tasks for source: {instance.name}')
|
||||||
delete_index_source_task(str(instance.pk))
|
delete_index_source_task(str(instance.pk))
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(task_failed, sender=Task)
|
||||||
|
def task_task_failed(sender, task_id, completed_task, **kwargs):
|
||||||
|
# Triggered after a source fails by reaching its max retry attempts
|
||||||
|
obj, url = map_task_to_instance(completed_task)
|
||||||
|
if isinstance(obj, Source):
|
||||||
|
log.error(f'Permanent failure for source: {obj} task: {completed_task}')
|
||||||
|
obj.has_failed = True
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Media)
|
@receiver(post_save, sender=Media)
|
||||||
def media_post_save(sender, instance, created, **kwargs):
|
def media_post_save(sender, instance, created, **kwargs):
|
||||||
# Triggered when media is saved
|
# Triggered after media is saved
|
||||||
if created:
|
if created:
|
||||||
# If the media is newly created fire a task off to download its thumbnail
|
# If the media is newly created fire a task off to download its thumbnail
|
||||||
metadata = instance.loaded_metadata
|
metadata = instance.loaded_metadata
|
||||||
thumbnail_url = metadata.get('thumbnail', '')
|
thumbnail_url = metadata.get('thumbnail', '')
|
||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
download_media_thumbnail(str(instance.pk), thumbnail_url)
|
log.info(f'Scheduling task to download thumbnail for: {instance.name} '
|
||||||
|
f'from: {thumbnail_url}')
|
||||||
|
verbose_name = _('Downloading media thumbnail for "{}')
|
||||||
|
download_media_thumbnail(
|
||||||
|
str(instance.pk),
|
||||||
|
thumbnail_url,
|
||||||
|
verbose_name=verbose_name.format(instance.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_delete, sender=Media)
|
@receiver(post_delete, sender=Media)
|
||||||
def media_post_delete(sender, instance, **kwargs):
|
def media_post_delete(sender, instance, **kwargs):
|
||||||
# Triggered when media is deleted, delete media thumbnail
|
# Triggered after media is deleted, delete media thumbnail
|
||||||
if instance.thumb:
|
if instance.thumb:
|
||||||
|
log.info(f'Deleting thumbnail for: {instance} path: {instance.thumb.path}')
|
||||||
delete_file(instance.thumb.path)
|
delete_file(instance.thumb.path)
|
||||||
|
|
|
@ -6,30 +6,109 @@
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
import uuid
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from hashlib import sha1
|
||||||
|
from datetime import timedelta
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
from background_task import background
|
from background_task import background
|
||||||
from background_task.models import Task
|
from background_task.models import Task, CompletedTask
|
||||||
from common.logger import log
|
from common.logger import log
|
||||||
|
from common.errors import NoMediaException
|
||||||
from .models import Source, Media
|
from .models import Source, Media
|
||||||
from .utils import get_remote_image, resize_image_to_height
|
from .utils import get_remote_image, resize_image_to_height
|
||||||
|
|
||||||
|
|
||||||
def delete_index_source_task(source_id):
|
def get_hash(task_name, pk):
|
||||||
task = None
|
'''
|
||||||
|
Create a background_task compatible hash for a Task or CompletedTask.
|
||||||
|
'''
|
||||||
|
task_params = json.dumps(((str(pk),), {}), sort_keys=True)
|
||||||
|
return sha1(f'{task_name}{task_params}'.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def map_task_to_instance(task):
|
||||||
|
'''
|
||||||
|
Reverse-maps an scheduled backgrond task to an instance. Requires the task name
|
||||||
|
to be a known task function and the first argument to be a UUID. This is used
|
||||||
|
because UUID's are incompatible with background_task's "creator" feature.
|
||||||
|
'''
|
||||||
|
TASK_MAP = {
|
||||||
|
'sync.tasks.index_source_task': Source,
|
||||||
|
'sync.tasks.download_media_thumbnail': Media,
|
||||||
|
}
|
||||||
|
MODEL_URL_MAP = {
|
||||||
|
Source: 'sync:source',
|
||||||
|
Media: 'sync:media-item',
|
||||||
|
}
|
||||||
|
task_func, task_args_str = task.task_name, task.task_params
|
||||||
|
model = TASK_MAP.get(task_func, None)
|
||||||
|
if not model:
|
||||||
|
return None, None
|
||||||
|
url = MODEL_URL_MAP.get(model, None)
|
||||||
|
if not url:
|
||||||
|
return None, None
|
||||||
try:
|
try:
|
||||||
# get_task currently returns a QuerySet, but catch DoesNotExist just in case
|
task_args = json.loads(task_args_str)
|
||||||
task = Task.objects.get_task('sync.tasks.index_source_task', args=(source_id,))
|
except (TypeError, ValueError, AttributeError):
|
||||||
except Task.DoesNotExist:
|
return None, None
|
||||||
pass
|
if len(task_args) != 2:
|
||||||
if task:
|
return None, None
|
||||||
# A scheduled task exists for this Source, delete it
|
args, kwargs = task_args
|
||||||
log.info(f'Deleting Source index task: {task}')
|
if len(args) == 0:
|
||||||
task.delete()
|
return None, None
|
||||||
|
instance_uuid_str = args[0]
|
||||||
|
try:
|
||||||
|
instance_uuid = uuid.UUID(instance_uuid_str)
|
||||||
|
except (TypeError, ValueError, AttributeError):
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
instance = model.objects.get(pk=instance_uuid)
|
||||||
|
return instance, url
|
||||||
|
except model.DoesNotExist:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def get_error_message(task):
|
||||||
|
'''
|
||||||
|
Extract an error message from a failed task.
|
||||||
|
'''
|
||||||
|
if not task.has_error():
|
||||||
|
return ''
|
||||||
|
stacktrace_lines = task.last_error.strip().split('\n')
|
||||||
|
if len(stacktrace_lines) == 0:
|
||||||
|
return ''
|
||||||
|
error_message = stacktrace_lines[-1].strip()
|
||||||
|
if ':' not in error_message:
|
||||||
|
return ''
|
||||||
|
return error_message.split(':', 1)[1].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def get_source_completed_tasks(source_id, only_errors=False):
|
||||||
|
'''
|
||||||
|
Returns a queryset of CompletedTask objects for a source by source ID.
|
||||||
|
'''
|
||||||
|
source_hash = get_hash('sync.tasks.index_source_task', source_id)
|
||||||
|
q = {'task_hash': source_hash}
|
||||||
|
if only_errors:
|
||||||
|
q['failed_at__isnull'] = False
|
||||||
|
return CompletedTask.objects.filter(**q).order_by('-failed_at')
|
||||||
|
|
||||||
|
|
||||||
|
def delete_index_source_task(source_id):
|
||||||
|
Task.objects.drop_task('sync.tasks.index_source_task', args=(source_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_completed_tasks():
|
||||||
|
days_to_keep = getattr(settings, 'COMPLETED_TASKS_DAYS_TO_KEEP', 30)
|
||||||
|
delta = timezone.now() - timedelta(days=days_to_keep)
|
||||||
|
log.info(f'Deleting completed tasks older than {days_to_keep} days '
|
||||||
|
f'(run_at before {delta})')
|
||||||
|
CompletedTask.objects.filter(run_at__lt=delta).delete()
|
||||||
|
|
||||||
|
|
||||||
@background(schedule=0)
|
@background(schedule=0)
|
||||||
|
@ -43,7 +122,20 @@ def index_source_task(source_id):
|
||||||
# Task triggered but the Source has been deleted, delete the task
|
# Task triggered but the Source has been deleted, delete the task
|
||||||
delete_index_source_task(source_id)
|
delete_index_source_task(source_id)
|
||||||
return
|
return
|
||||||
|
# Reset any errors
|
||||||
|
source.has_failed = False
|
||||||
|
source.save()
|
||||||
|
# Index the source
|
||||||
videos = source.index_media()
|
videos = source.index_media()
|
||||||
|
if not videos:
|
||||||
|
raise NoMediaException(f'Source "{source}" (ID: {source_id}) returned no '
|
||||||
|
f'media to index, is the source key valid? Check the '
|
||||||
|
f'source configuration is correct and that the source '
|
||||||
|
f'is reachable')
|
||||||
|
# Got some media, update the last crawl timestamp
|
||||||
|
source.last_crawl = timezone.now()
|
||||||
|
source.save()
|
||||||
|
log.info(f'Found {len(videos)} media items for source: {source}')
|
||||||
for video in videos:
|
for video in videos:
|
||||||
# Create or update each video as a Media object
|
# Create or update each video as a Media object
|
||||||
key = video.get(source.key_field, None)
|
key = video.get(source.key_field, None)
|
||||||
|
@ -64,6 +156,8 @@ def index_source_task(source_id):
|
||||||
log.info(f'Indexed media: {source} / {media}')
|
log.info(f'Indexed media: {source} / {media}')
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
log.error(f'Index media failed: {source} / {media} with "{e}"')
|
log.error(f'Index media failed: {source} / {media} with "{e}"')
|
||||||
|
# Tack on a cleanup of old completed tasks
|
||||||
|
cleanup_completed_tasks()
|
||||||
|
|
||||||
|
|
||||||
@background(schedule=0)
|
@background(schedule=0)
|
||||||
|
|
|
@ -3,6 +3,11 @@
|
||||||
{% block headtitle %}Dashboard{% endblock %}
|
{% block headtitle %}Dashboard{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s12">
|
||||||
|
<h1 class="truncate">Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
|
@ -3,6 +3,11 @@
|
||||||
{% block headtitle %}Media{% if source %} - {{ source }}{% endif %}{% endblock %}
|
{% block headtitle %}Media{% if source %} - {{ source }}{% endif %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s12">
|
||||||
|
<h1 class="truncate">Media</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% include 'infobox.html' with message=message %}
|
{% include 'infobox.html' with message=message %}
|
||||||
<div class="row no-margin-bottom">
|
<div class="row no-margin-bottom">
|
||||||
{% for m in media %}
|
{% for m in media %}
|
||||||
|
@ -22,7 +27,7 @@
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
<div class="collection">
|
<div class="collection">
|
||||||
<span class="collection-item no-items"><i class="fas fa-info-circle"></i> No media has been indexed.</span>
|
<span class="collection-item no-items"><i class="fas fa-info-circle"></i> No media has been indexed{% if source %} that matches the specified source filter{% endif %}.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
@ -3,14 +3,22 @@
|
||||||
{% block headtitle %}Source - {{ source.name }}{% endblock %}
|
{% block headtitle %}Source - {{ source.name }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row no-margin-bottom">
|
||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
<h1 class="truncate">Source <strong>{{ source.name }}</strong></h1>
|
<h1 class="truncate">Source <strong>{{ source.name }}</strong></h1>
|
||||||
<p class="truncate"><strong><a href="{{ source.url }}" target="_blank"><i class="fas fa-link"></i> {{ source.url }}</a></strong></p>
|
<p class="truncate"><strong><a href="{{ source.url }}" target="_blank"><i class="fas fa-link"></i> {{ source.url }}</a></strong></p>
|
||||||
<p class="truncate">Saving to: <strong>{{ source.directory_path }}</strong></p>
|
<p class="truncate">Saving to: <strong>{{ source.directory_path }}</strong></p>
|
||||||
<p><a href="{% url 'sync:media' %}?filter={{ source.pk }}" class="btn">Media<span class="hide-on-small-only"> linked to this source</span> <i class="fas fa-fw fa-film"></i></a></p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s12 l6 margin-bottom">
|
||||||
|
<a href="{% url 'sync:media' %}?filter={{ source.pk }}" class="btn">View media<span class="hide-on-small-only"> linked to this source</span> <i class="fas fa-fw fa-film"></i></a>
|
||||||
|
</div>
|
||||||
|
<div class="col s12 l6 margin-bottom">
|
||||||
|
<a href="{% url 'sync:tasks-completed' %}?filter={{ source.pk }}" class="btn">View tasks<span class="hide-on-small-only"> linked to this source</span> <i class="far fa-fw fa-clock"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if source.has_failed %}{% include 'errorbox.html' with message='This source has encountered permanent failures listed at the bottom of this page, check its settings' %}{% endif %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
<table class="striped">
|
<table class="striped">
|
||||||
|
@ -22,6 +30,10 @@
|
||||||
<td class="hide-on-small-only">Name</td>
|
<td class="hide-on-small-only">Name</td>
|
||||||
<td><span class="hide-on-med-and-up">Name<br></span><strong>{{ source.name }}</strong></td>
|
<td><span class="hide-on-med-and-up">Name<br></span><strong>{{ source.name }}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr title="Number of media items downloaded for the source">
|
||||||
|
<td class="hide-on-small-only">Media items</td>
|
||||||
|
<td><span class="hide-on-med-and-up">Media items<br></span><strong><a href="{% url 'sync:media' %}?filter={{ source.pk }}">{{ media|length }}</a></strong></td>
|
||||||
|
</tr>
|
||||||
<tr title="Unique key of the source, such as the channel name or playlist ID">
|
<tr title="Unique key of the source, such as the channel name or playlist ID">
|
||||||
<td class="hide-on-small-only">Key</td>
|
<td class="hide-on-small-only">Key</td>
|
||||||
<td><span class="hide-on-med-and-up">Key<br></span><strong>{{ source.key }}</strong></td>
|
<td><span class="hide-on-med-and-up">Key<br></span><strong>{{ source.key }}</strong></td>
|
||||||
|
@ -98,4 +110,20 @@
|
||||||
<a href="{% url 'sync:delete-source' pk=source.pk %}" class="btn delete-button">Delete source <i class="fas fa-trash-alt"></i></a>
|
<a href="{% url 'sync:delete-source' pk=source.pk %}" class="btn delete-button">Delete source <i class="fas fa-trash-alt"></i></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if errors %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s12">
|
||||||
|
<h2>Source has encountered {{ errors|length }} Error{{ errors|length|pluralize }}</h2>
|
||||||
|
<div class="collection">
|
||||||
|
{% for task in errors %}
|
||||||
|
<span class="collection-item error-text">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i> <strong>{{ task.verbose_name }}</strong><br>
|
||||||
|
Error: "{{ task.error_message }}"<br>
|
||||||
|
<i class="far fa-clock"></i> Occured at <strong>{{ task.run_at|date:'Y-m-d H:i:s' }}</strong>
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -3,8 +3,13 @@
|
||||||
{% block headtitle %}Sources{% endblock %}
|
{% block headtitle %}Sources{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s12">
|
||||||
|
<h1 class="truncate">Sources</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% include 'infobox.html' with message=message %}
|
{% include 'infobox.html' with message=message %}
|
||||||
<div class="row no-margin-bottom">
|
<div class="row">
|
||||||
<div class="col s12 l6 margin-bottom">
|
<div class="col s12 l6 margin-bottom">
|
||||||
<a href="{% url 'sync:validate-source' source_type='youtube-channel' %}" class="btn">Add a YouTube channel <i class="fab fa-youtube"></i></a>
|
<a href="{% url 'sync:validate-source' source_type='youtube-channel' %}" class="btn">Add a YouTube channel <i class="fab fa-youtube"></i></a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -17,9 +22,13 @@
|
||||||
<div class="collection">
|
<div class="collection">
|
||||||
{% for source in sources %}
|
{% for source in sources %}
|
||||||
<a href="{% url 'sync:source' pk=source.pk %}" class="collection-item">
|
<a href="{% url 'sync:source' pk=source.pk %}" class="collection-item">
|
||||||
{{ source.icon|safe }} <strong>{{ source.name }}</strong> ({{ source.get_source_type_display }})<br>
|
{{ source.icon|safe }} <strong>{{ source.name }}</strong> ({{ source.get_source_type_display }} "{{ source.key }}")<br>
|
||||||
{{ source.format_summary }}<br>
|
{{ source.format_summary }}<br>
|
||||||
|
{% if source.has_failed %}
|
||||||
|
<span class="error-text"><i class="fas fa-exclamation-triangle"></i> <strong>Source has permanent failures</strong></span>
|
||||||
|
{% else %}
|
||||||
<strong>{{ source.media_count }}</strong> media items{% if source.delete_old_media and source.days_to_keep > 0 %}, keep {{ source.days_to_keep }} days of media{% endif %}
|
<strong>{{ source.media_count }}</strong> media items{% if source.delete_old_media and source.days_to_keep > 0 %}, keep {{ source.days_to_keep }} days of media{% endif %}
|
||||||
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<span class="collection-item no-items"><i class="fas fa-info-circle"></i> You haven't added any sources.</span>
|
<span class="collection-item no-items"><i class="fas fa-info-circle"></i> You haven't added any sources.</span>
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block headtitle %}Tasks - Completed{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s12">
|
||||||
|
<h1 class="truncate">Completed tasks</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include 'infobox.html' with message=message %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s12">
|
||||||
|
<div class="collection">
|
||||||
|
{% for task in tasks %}
|
||||||
|
<span class="collection-item">
|
||||||
|
{% if task.has_error %}
|
||||||
|
<i class="fas fa-exclamation-triangle"></i> <strong>{{ task.verbose_name }}</strong><br>
|
||||||
|
Error: "{{ task.error_message }}"<br>
|
||||||
|
<i class="far fa-clock"></i> Task started at <strong>{{ task.run_at|date:'Y-m-d H:i:s' }}</strong>
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-check"></i> <strong>{{ task.verbose_name }}</strong><br>
|
||||||
|
<i class="far fa-clock"></i> Task started at <strong>{{ task.run_at|date:'Y-m-d H:i:s' }}</strong>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% empty %}
|
||||||
|
<span class="collection-item no-items"><i class="fas fa-info-circle"></i> There have been no completed tasks.</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include 'pagination.html' with pagination=sources.paginator filter=source.pk %}
|
||||||
|
{% endblock %}
|
|
@ -5,7 +5,83 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
tasks
|
<h1>Tasks</h1>
|
||||||
|
<p>
|
||||||
|
Tasks are the background work that TubeSync undertakes to index and download
|
||||||
|
media. This page allows you to see basic overview of what is running and what is
|
||||||
|
scheduled to perform in the future as well as check up on any errors that might
|
||||||
|
have occured.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s12">
|
||||||
|
<h2>{{ running|length }} Running</h2>
|
||||||
|
<p>
|
||||||
|
Running tasks are tasks which currently being worked on right now.
|
||||||
|
</p>
|
||||||
|
<div class="collection">
|
||||||
|
{% for task in running %}
|
||||||
|
<a href="{% url task.url pk=task.instance.pk %}" class="collection-item">
|
||||||
|
<i class="fas fa-running"></i> <strong>{{ task }}</strong><br>
|
||||||
|
<i class="far fa-clock"></i> Task started at <strong>{{ task.run_at|date:'Y-m-d H:i:s' }}</strong>
|
||||||
|
</a>
|
||||||
|
{% empty %}
|
||||||
|
<span class="collection-item no-items"><i class="fas fa-info-circle"></i> There are no running tasks.</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s12">
|
||||||
|
<h2>{{ errors|length }} Error{{ errors|length|pluralize }}</h2>
|
||||||
|
<p>
|
||||||
|
Tasks which generated an error are shown here. Tasks are retried a couple of
|
||||||
|
times, so if there was an intermittent error such as a download got interrupted
|
||||||
|
it will be scheduled to run again.
|
||||||
|
</p>
|
||||||
|
<div class="collection">
|
||||||
|
{% for task in errors %}
|
||||||
|
<a href="{% url task.url pk=task.instance.pk %}" class="collection-item error-text">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i> <strong>{{ task }}</strong>, attempted {{ task.attempts }} time{{ task.attempts|pluralize }}<br>
|
||||||
|
Error: "{{ task.error_message }}"<br>
|
||||||
|
<i class="fas fa-history"></i> Task will be retried at <strong>{{ task.run_at|date:'Y-m-d H:i:s' }}</strong>
|
||||||
|
</a>
|
||||||
|
{% empty %}
|
||||||
|
<span class="collection-item no-items"><i class="fas fa-info-circle"></i> There are no tasks with errors.</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s12">
|
||||||
|
<h2>{{ scheduled|length }} Scheduled</h2>
|
||||||
|
<p>
|
||||||
|
Tasks which are scheduled to run in the future or are waiting in a queue to be
|
||||||
|
processed. They can be waiting for an available worker to run immediately, or
|
||||||
|
run in the future at the specified "run at" time.
|
||||||
|
</p>
|
||||||
|
<div class="collection">
|
||||||
|
{% for task in scheduled %}
|
||||||
|
<a href="{% url task.url pk=task.instance.pk %}" class="collection-item">
|
||||||
|
<i class="far fa-stopwatch"></i> <strong>{{ task }}</strong><br>
|
||||||
|
Scheduled to run {{ task.instance.get_index_schedule_display|lower }}.<br>
|
||||||
|
<i class="fas fa-redo"></i> Task will run at <strong>{{ task.run_at|date:'Y-m-d H:i:s' }}</strong>
|
||||||
|
</a>
|
||||||
|
{% empty %}
|
||||||
|
<span class="collection-item no-items"><i class="fas fa-info-circle"></i> There are no scheduled tasks.</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col s12">
|
||||||
|
<h2>Completed</h2>
|
||||||
|
<p>
|
||||||
|
A record of recently completed tasks is kept for a few days. You can use the button
|
||||||
|
below to view recent tasks which have completed successfully.
|
||||||
|
</p>
|
||||||
|
<a href="{% url 'sync:tasks-completed' %}" class="btn"><span class="hide-on-med-and-down">View </span>Completed tasks <i class="fas fa-check-double"></i></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from .views import (DashboardView, SourcesView, ValidateSourceView, AddSourceView,
|
from .views import (DashboardView, SourcesView, ValidateSourceView, AddSourceView,
|
||||||
SourceView, UpdateSourceView, DeleteSourceView, MediaView,
|
SourceView, UpdateSourceView, DeleteSourceView, MediaView,
|
||||||
MediaThumbView, MediaItemView, TasksView)
|
MediaThumbView, MediaItemView, TasksView, CompletedTasksView)
|
||||||
|
|
||||||
|
|
||||||
app_name = 'sync'
|
app_name = 'sync'
|
||||||
|
@ -61,4 +61,8 @@ urlpatterns = [
|
||||||
TasksView.as_view(),
|
TasksView.as_view(),
|
||||||
name='tasks'),
|
name='tasks'),
|
||||||
|
|
||||||
|
path('tasks-completed',
|
||||||
|
CompletedTasksView.as_view(),
|
||||||
|
name='tasks-completed'),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
|
@ -14,25 +14,31 @@ def validate_url(url, validator):
|
||||||
Validate a URL against a dict of validation requirements. Returns an extracted
|
Validate a URL against a dict of validation requirements. Returns an extracted
|
||||||
part of the URL if the URL is valid, if invalid raises a ValidationError.
|
part of the URL if the URL is valid, if invalid raises a ValidationError.
|
||||||
'''
|
'''
|
||||||
valid_scheme, valid_netloc, valid_path, valid_query, extract_parts = (
|
valid_scheme, valid_netloc, valid_path, invalid_paths, valid_query, \
|
||||||
validator['scheme'], validator['domain'], validator['path_regex'],
|
extract_parts = (
|
||||||
validator['qs_args'], validator['extract_key'])
|
validator['scheme'], validator['domain'], validator['path_regex'],
|
||||||
|
validator['path_must_not_match'], validator['qs_args'],
|
||||||
|
validator['extract_key']
|
||||||
|
)
|
||||||
url_parts = urlsplit(str(url).strip())
|
url_parts = urlsplit(str(url).strip())
|
||||||
url_scheme = str(url_parts.scheme).strip().lower()
|
url_scheme = str(url_parts.scheme).strip().lower()
|
||||||
if url_scheme != valid_scheme:
|
if url_scheme != valid_scheme:
|
||||||
raise ValidationError(f'scheme "{url_scheme}" must be "{valid_scheme}"')
|
raise ValidationError(f'invalid scheme "{url_scheme}" must be "{valid_scheme}"')
|
||||||
url_netloc = str(url_parts.netloc).strip().lower()
|
url_netloc = str(url_parts.netloc).strip().lower()
|
||||||
if url_netloc != valid_netloc:
|
if url_netloc != valid_netloc:
|
||||||
raise ValidationError(f'domain "{url_netloc}" must be "{valid_netloc}"')
|
raise ValidationError(f'invalid domain "{url_netloc}" must be "{valid_netloc}"')
|
||||||
url_path = str(url_parts.path).strip()
|
url_path = str(url_parts.path).strip()
|
||||||
matches = re.findall(valid_path, url_path)
|
matches = re.findall(valid_path, url_path)
|
||||||
if not matches:
|
if not matches:
|
||||||
raise ValidationError(f'path "{url_path}" must match "{valid_path}"')
|
raise ValidationError(f'invalid path "{url_path}" must match "{valid_path}"')
|
||||||
|
for invalid_path in invalid_paths:
|
||||||
|
if url_path.lower() == invalid_path.lower():
|
||||||
|
raise ValidationError(f'path "{url_path}" is not valid')
|
||||||
url_query = str(url_parts.query).strip()
|
url_query = str(url_parts.query).strip()
|
||||||
url_query_parts = parse_qs(url_query)
|
url_query_parts = parse_qs(url_query)
|
||||||
for required_query in valid_query:
|
for required_query in valid_query:
|
||||||
if required_query not in url_query_parts:
|
if required_query not in url_query_parts:
|
||||||
raise ValidationError(f'query string "{url_query}" must '
|
raise ValidationError(f'invalid query string "{url_query}" must '
|
||||||
f'contain the parameter "{required_query}"')
|
f'contain the parameter "{required_query}"')
|
||||||
extract_from, extract_param = extract_parts
|
extract_from, extract_param = extract_parts
|
||||||
extract_value = ''
|
extract_value = ''
|
||||||
|
@ -76,7 +82,7 @@ def resize_image_to_height(image, width, height):
|
||||||
if scaled_width > width:
|
if scaled_width > width:
|
||||||
# Width too large, crop it
|
# Width too large, crop it
|
||||||
delta = scaled_width - width
|
delta = scaled_width - width
|
||||||
left, upper = (delta / 2), 0
|
left, upper = round(delta / 2), 0
|
||||||
right, lower = (left + width), height
|
right, lower = (left + width), height
|
||||||
image = image.crop((left, upper, right, lower))
|
image = image.crop((left, upper, right, lower))
|
||||||
return image
|
return image
|
||||||
|
|
|
@ -9,11 +9,14 @@ from django.urls import reverse_lazy
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.forms import ValidationError
|
from django.forms import ValidationError
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from common.utils import append_uri_params
|
from common.utils import append_uri_params
|
||||||
|
from background_task.models import Task, CompletedTask
|
||||||
from .models import Source, Media
|
from .models import Source, Media
|
||||||
from .forms import ValidateSourceForm, ConfirmDeleteSourceForm
|
from .forms import ValidateSourceForm, ConfirmDeleteSourceForm
|
||||||
from .utils import validate_url
|
from .utils import validate_url
|
||||||
|
from .tasks import map_task_to_instance, get_error_message, get_source_completed_tasks
|
||||||
from . import signals
|
from . import signals
|
||||||
from . import youtube
|
from . import youtube
|
||||||
|
|
||||||
|
@ -108,6 +111,7 @@ class ValidateSourceView(FormView):
|
||||||
'scheme': 'https',
|
'scheme': 'https',
|
||||||
'domain': 'www.youtube.com',
|
'domain': 'www.youtube.com',
|
||||||
'path_regex': '^\/(c\/)?([^\/]+)$',
|
'path_regex': '^\/(c\/)?([^\/]+)$',
|
||||||
|
'path_must_not_match': ('/playlist',),
|
||||||
'qs_args': [],
|
'qs_args': [],
|
||||||
'extract_key': ('path_regex', 1),
|
'extract_key': ('path_regex', 1),
|
||||||
'example': 'https://www.youtube.com/SOMECHANNEL'
|
'example': 'https://www.youtube.com/SOMECHANNEL'
|
||||||
|
@ -116,7 +120,8 @@ class ValidateSourceView(FormView):
|
||||||
'scheme': 'https',
|
'scheme': 'https',
|
||||||
'domain': 'www.youtube.com',
|
'domain': 'www.youtube.com',
|
||||||
'path_regex': '^\/(playlist|watch)$',
|
'path_regex': '^\/(playlist|watch)$',
|
||||||
'qs_args': ['list'],
|
'path_must_not_match': (),
|
||||||
|
'qs_args': ('list',),
|
||||||
'extract_key': ('qs_args', 'list'),
|
'extract_key': ('qs_args', 'list'),
|
||||||
'example': 'https://www.youtube.com/playlist?list=PLAYLISTID'
|
'example': 'https://www.youtube.com/playlist?list=PLAYLISTID'
|
||||||
},
|
},
|
||||||
|
@ -239,6 +244,16 @@ class SourceView(DetailView):
|
||||||
template_name = 'sync/source.html'
|
template_name = 'sync/source.html'
|
||||||
model = Source
|
model = Source
|
||||||
|
|
||||||
|
def get_context_data(self, *args, **kwargs):
|
||||||
|
data = super().get_context_data(*args, **kwargs)
|
||||||
|
data['errors'] = []
|
||||||
|
for error in get_source_completed_tasks(self.object.pk, only_errors=True):
|
||||||
|
error_message = get_error_message(error)
|
||||||
|
setattr(error, 'error_message', error_message)
|
||||||
|
data['errors'].append(error)
|
||||||
|
data['media'] = Media.objects.filter(source=self.object).order_by('-published')
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class UpdateSourceView(UpdateView):
|
class UpdateSourceView(UpdateView):
|
||||||
|
|
||||||
|
@ -286,7 +301,7 @@ class MediaView(ListView):
|
||||||
context_object_name = 'media'
|
context_object_name = 'media'
|
||||||
paginate_by = settings.MEDIA_PER_PAGE
|
paginate_by = settings.MEDIA_PER_PAGE
|
||||||
messages = {
|
messages = {
|
||||||
'filter': _('Viewing media for source: <strong>{name}</strong>'),
|
'filter': _('Viewing media filtered for source: <strong>{name}</strong>'),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
@ -352,13 +367,90 @@ class MediaItemView(DetailView):
|
||||||
model = Media
|
model = Media
|
||||||
|
|
||||||
|
|
||||||
class TasksView(TemplateView):
|
class TasksView(ListView):
|
||||||
'''
|
'''
|
||||||
A list of tasks queued to be completed. Typically, this is scraping for new
|
A list of tasks queued to be completed. Typically, this is scraping for new
|
||||||
media or downloading media.
|
media or downloading media.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
template_name = 'sync/tasks.html'
|
template_name = 'sync/tasks.html'
|
||||||
|
context_object_name = 'tasks'
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Task.objects.all().order_by('run_at')
|
||||||
|
|
||||||
|
def get_context_data(self, *args, **kwargs):
|
||||||
|
data = super().get_context_data(*args, **kwargs)
|
||||||
|
data['running'] = []
|
||||||
|
data['errors'] = []
|
||||||
|
data['scheduled'] = []
|
||||||
|
queryset = self.get_queryset()
|
||||||
|
now = timezone.now()
|
||||||
|
for task in queryset:
|
||||||
|
obj, url = map_task_to_instance(task)
|
||||||
|
if not obj:
|
||||||
|
# Orphaned task, ignore it (it will be deleted when it fires)
|
||||||
|
continue
|
||||||
|
setattr(task, 'instance', obj)
|
||||||
|
setattr(task, 'url', url)
|
||||||
|
if task.locked_by_pid_running():
|
||||||
|
data['running'].append(task)
|
||||||
|
elif task.has_error():
|
||||||
|
error_message = get_error_message(task)
|
||||||
|
setattr(task, 'error_message', error_message)
|
||||||
|
data['errors'].append(task)
|
||||||
|
else:
|
||||||
|
data['scheduled'].append(task)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class CompletedTasksView(ListView):
|
||||||
|
'''
|
||||||
|
List of tasks which have been completed with an optional per-source filter.
|
||||||
|
'''
|
||||||
|
|
||||||
|
template_name = 'sync/tasks-completed.html'
|
||||||
|
context_object_name = 'tasks'
|
||||||
|
paginate_by = settings.TASKS_PER_PAGE
|
||||||
|
messages = {
|
||||||
|
'filter': _('Viewing tasks filtered for source: <strong>{name}</strong>'),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.filter_source = None
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
filter_by = request.GET.get('filter', '')
|
||||||
|
if filter_by:
|
||||||
|
try:
|
||||||
|
self.filter_source = Source.objects.get(pk=filter_by)
|
||||||
|
except Source.DoesNotExist:
|
||||||
|
self.filter_source = None
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return CompletedTask.objects.all().order_by('-run_at')
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
if self.filter_source:
|
||||||
|
return CompletedTask.objects.all().order_by('-run_at')
|
||||||
|
#tasks = []
|
||||||
|
#for task in CompletedTask.objects.all().order_by('-run_at'):
|
||||||
|
# # ???
|
||||||
|
#q = Media.objects.filter(source=self.filter_source)
|
||||||
|
return CompletedTask.objects.all().order_by('-run_at')
|
||||||
|
|
||||||
|
def get_context_data(self, *args, **kwargs):
|
||||||
|
data = super().get_context_data(*args, **kwargs)
|
||||||
|
for task in data['tasks']:
|
||||||
|
if task.has_error():
|
||||||
|
error_message = get_error_message(task)
|
||||||
|
setattr(task, 'error_message', error_message)
|
||||||
|
data['message'] = ''
|
||||||
|
data['source'] = None
|
||||||
|
if self.filter_source:
|
||||||
|
message = str(self.messages.get('filter', ''))
|
||||||
|
data['message'] = message.format(name=self.filter_source.name)
|
||||||
|
data['source'] = self.filter_source
|
||||||
|
return data
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
ROOT_DIR = Path('/')
|
ROOT_DIR = Path('/')
|
||||||
|
|
||||||
|
|
||||||
SECRET_KEY = str(os.getenv('DJANGO_SECRET_KEY', ''))
|
RANDOM_SECRET = hexlify(os.urandom(32)).decode()
|
||||||
ALLOWED_HOSTS_STR = str(os.getenv('DJANGO_ALLOWED_HOSTS', ''))
|
SECRET_KEY = str(os.getenv('DJANGO_SECRET_KEY', RANDOM_SECRET))
|
||||||
|
ALLOWED_HOSTS_STR = str(os.getenv('TUBESYNC_HOSTS', 'localhost'))
|
||||||
ALLOWED_HOSTS = ALLOWED_HOSTS_STR.split(',')
|
ALLOWED_HOSTS = ALLOWED_HOSTS_STR.split(',')
|
||||||
|
|
||||||
|
|
||||||
|
@ -19,6 +21,9 @@ DATABASES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
BACKGROUND_TASK_ASYNC_THREADS = int(os.get('TUBESYNC_WORKERS', 2))
|
||||||
|
|
||||||
|
|
||||||
MEDIA_ROOT = ROOT_DIR / 'config' / 'media'
|
MEDIA_ROOT = ROOT_DIR / 'config' / 'media'
|
||||||
SYNC_VIDEO_ROOT = ROOT_DIR / 'downloads' / 'video'
|
SYNC_VIDEO_ROOT = ROOT_DIR / 'downloads' / 'video'
|
||||||
SYNC_AUDIO_ROOT = ROOT_DIR / 'downloads' / 'audio'
|
SYNC_AUDIO_ROOT = ROOT_DIR / 'downloads' / 'audio'
|
||||||
|
|
|
@ -118,10 +118,12 @@ MAX_RUN_TIME = 1800 # Maximum amount of time in seconds
|
||||||
BACKGROUND_TASK_RUN_ASYNC = True # Run tasks async in the background
|
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_ASYNC_THREADS = 2 # Number of async tasks to run at once
|
||||||
BACKGROUND_TASK_PRIORITY_ORDERING = 'DESC' # Process high priority tasks first
|
BACKGROUND_TASK_PRIORITY_ORDERING = 'DESC' # Process high priority tasks first
|
||||||
|
COMPLETED_TASKS_DAYS_TO_KEEP = 30 # Number of days to keep completed tasks
|
||||||
|
|
||||||
|
|
||||||
SOURCES_PER_PAGE = 36
|
SOURCES_PER_PAGE = 36
|
||||||
MEDIA_PER_PAGE = 36
|
MEDIA_PER_PAGE = 36
|
||||||
|
TASKS_PER_PAGE = 100
|
||||||
|
|
||||||
|
|
||||||
MEDIA_THUMBNAIL_WIDTH = 430 # Width in pixels to resize thumbnails to
|
MEDIA_THUMBNAIL_WIDTH = 430 # Width in pixels to resize thumbnails to
|
||||||
|
|
Loading…
Reference in New Issue