Implemented video management facilities.

This commit is contained in:
Tiberiu Chibici 2018-10-21 01:20:31 +03:00
parent 43e00e935b
commit 1dd9a3cf02
20 changed files with 859 additions and 466 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,11 @@
from .appconfig import initialize_app_config from .appconfig import initialize_app_config
from .scheduler import initialize_scheduler from .scheduler import initialize_scheduler
from .management.jobs.synchronize import schedule_synchronize from .management.jobs.synchronize import schedule_synchronize_global
import logging import logging
def main(): def main():
initialize_app_config() initialize_app_config()
initialize_scheduler() initialize_scheduler()
schedule_synchronize() schedule_synchronize_global()
logging.info('Initialization complete.') logging.info('Initialization complete.')

View File

@ -6,8 +6,6 @@ class YtManagerAppConfig(AppConfig):
name = 'YtManagerApp' name = 'YtManagerApp'
def ready(self): def ready(self):
# There seems to be a problem related to the auto-reload functionality where ready() is called twice # Run server using --noreload to avoid having the scheduler run on 2 different processes
# (in different processes). This seems like a good enough workaround (other than --noreload). from .appmain import main
if not os.getenv('RUN_MAIN', False): main()
from .appmain import main
main()

View File

@ -35,7 +35,7 @@ def __get_subscription_config(sub: Subscription):
return enabled, global_limit, limit, order return enabled, global_limit, limit, order
def __process_subscription(sub: Subscription): def downloader_process_subscription(sub: Subscription):
log.info('Processing subscription %d [%s %s]', sub.id, sub.playlist_id, sub.id) log.info('Processing subscription %d [%s %s]', sub.id, sub.playlist_id, sub.id)
enabled, global_limit, limit, order = __get_subscription_config(sub) enabled, global_limit, limit, order = __get_subscription_config(sub)
@ -70,7 +70,7 @@ def __process_subscription(sub: Subscription):
def downloader_process_all(): def downloader_process_all():
for subscription in Subscription.objects.all(): for subscription in Subscription.objects.all():
__process_subscription(subscription) downloader_process_subscription(subscription)
def fetch_thumbnail(url, object_type, identifier, quality): def fetch_thumbnail(url, object_type, identifier, quality):

View File

@ -0,0 +1,38 @@
import logging
import os
from YtManagerApp import scheduler
from YtManagerApp.models import Video
log = logging.getLogger('video_downloader')
def delete_video(video: Video):
log.info('Deleting video %d [%s %s]', video.id, video.video_id, video.name)
count = 0
try:
for file in video.get_files():
log.info("Deleting file %s", file)
count += 1
try:
os.unlink(file)
except OSError as e:
log.error("Failed to delete file %s: Error: %s", file, e)
except OSError as e:
log.error("Failed to delete video %d [%s %s]. Error: %s", video.id, video.video_id, video.name, e)
video.downloaded_path = None
video.save()
log.info('Deleted video %d successfully! (%d files) [%s %s]', video.id, count, video.video_id, video.name)
def schedule_delete_video(video: Video):
"""
Schedules a download video job to run immediately.
:param video:
:return:
"""
scheduler.instance.add_job(delete_video, args=[video])

View File

@ -4,11 +4,24 @@ from YtManagerApp.appconfig import get_user_config
import os import os
import youtube_dl import youtube_dl
import logging import logging
import re
log = logging.getLogger('video_downloader') log = logging.getLogger('video_downloader')
log_youtube_dl = log.getChild('youtube_dl') log_youtube_dl = log.getChild('youtube_dl')
def __get_valid_path(path):
"""
Normalizes string, converts to lowercase, removes non-alpha characters,
and converts spaces to hyphens.
"""
import unicodedata
value = unicodedata.normalize('NFKD', path).encode('ascii', 'ignore').decode('ascii')
value = re.sub('[:"*]', '', value).strip()
value = re.sub('[?<>|]', '#', value)
return value
def __build_youtube_dl_params(video: Video, user_config): def __build_youtube_dl_params(video: Video, user_config):
# resolve path # resolve path
format_dict = { format_dict = {
@ -24,7 +37,7 @@ def __build_youtube_dl_params(video: Video, user_config):
user_config.set_additional_interpolation_options(**format_dict) user_config.set_additional_interpolation_options(**format_dict)
download_path = user_config.get('user', 'DownloadPath') download_path = user_config.get('user', 'DownloadPath')
output_pattern = user_config.get('user', 'DownloadFilePattern') output_pattern = __get_valid_path(user_config.get('user', 'DownloadFilePattern'))
output_path = os.path.join(download_path, output_pattern) output_path = os.path.join(download_path, output_pattern)
output_path = os.path.normpath(output_path) output_path = os.path.normpath(output_path)
@ -39,7 +52,7 @@ def __build_youtube_dl_params(video: Video, user_config):
'allsubtitles': user_config.getboolean('user', 'DownloadSubtitlesAll'), 'allsubtitles': user_config.getboolean('user', 'DownloadSubtitlesAll'),
'postprocessors': [ 'postprocessors': [
{ {
'key': 'FFmpegMetadataPP' 'key': 'FFmpegMetadata'
}, },
] ]
} }
@ -72,7 +85,7 @@ def download_video(video: Video, attempt: int = 1):
if ret == 0: if ret == 0:
video.downloaded_path = output_path video.downloaded_path = output_path
video.save() video.save()
log.error('Video %d [%s %s] downloaded successfully!', video.id, video.video_id, video.name) log.info('Video %d [%s %s] downloaded successfully!', video.id, video.video_id, video.name)
elif attempt <= max_attempts: elif attempt <= max_attempts:
log.warning('Re-enqueueing video (attempt %d/%d)', attempt, max_attempts) log.warning('Re-enqueueing video (attempt %d/%d)', attempt, max_attempts)

View File

@ -1,24 +1,67 @@
import logging
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from threading import Lock
import os
import errno
import mimetypes
from YtManagerApp.appconfig import settings from YtManagerApp import scheduler
from YtManagerApp.management.downloader import fetch_thumbnail, downloader_process_all from YtManagerApp.appconfig import settings, get_user_config
from YtManagerApp.management.downloader import fetch_thumbnail, downloader_process_all, downloader_process_subscription
from YtManagerApp.management.videos import create_video from YtManagerApp.management.videos import create_video
from YtManagerApp.models import * from YtManagerApp.models import *
from YtManagerApp import scheduler
from YtManagerApp.utils.youtube import YoutubeAPI from YtManagerApp.utils.youtube import YoutubeAPI
log = logging.getLogger('sync') log = logging.getLogger('sync')
__lock = Lock()
def __synchronize_sub(subscription: Subscription, yt_api: YoutubeAPI): def __check_new_videos_sub(subscription: Subscription, yt_api: YoutubeAPI):
# Get list of videos # Get list of videos
for video in yt_api.list_playlist_videos(subscription.playlist_id): for video in yt_api.list_playlist_videos(subscription.playlist_id):
results = Video.objects.filter(video_id=video.getVideoId(), subscription=subscription) results = Video.objects.filter(video_id=video.getVideoId(), subscription=subscription)
if len(results) == 0: if len(results) == 0:
log.info('New video for subscription "', subscription, '": ', video.getVideoId(), video.getTitle()) log.info('New video for subscription %s: %s %s"', subscription, video.getVideoId(), video.getTitle())
create_video(video, subscription) create_video(video, subscription)
else:
# TODO... update view count, rating etc
pass
def __detect_deleted(subscription: Subscription):
user_settings = get_user_config(subscription.user)
for video in Video.objects.filter(subscription=subscription, downloaded_path__isnull=False):
found_video = False
files = []
try:
files = list(video.get_files())
except OSError as e:
if e.errno != errno.ENOENT:
log.error("Could not access path %s. Error: %s", video.downloaded_path, e)
return
# Try to find a valid video file
for file in files:
mime, _ = mimetypes.guess_type(file)
if mime is not None and mime.startswith("video"):
found_video = True
# Video not found, we can safely assume that the video was deleted.
if not found_video:
log.info("Video %d was deleted! [%s %s]", video.id, video.video_id, video.name)
# Clean up
for file in files:
try:
os.unlink(file)
except OSError as e:
log.error("Could not delete redundant file %s. Error: %s", file, e)
video.downloaded_path = None
# Mark watched?
if user_settings.getboolean('user', 'MarkDeletedAsWatched'):
video.watched = True
video.save()
def __fetch_thumbnails_obj(iterable, obj_type, id_attr): def __fetch_thumbnails_obj(iterable, obj_type, id_attr):
@ -46,23 +89,63 @@ def __fetch_thumbnails():
def synchronize(): def synchronize():
log.info("Running scheduled synchronization... ") if not __lock.acquire(blocking=False):
# Synchronize already running in another thread
log.info("Synchronize already running in another thread")
return
# Sync subscribed playlists/channels try:
log.info("Sync - checking for new videos") log.info("Running scheduled synchronization... ")
yt_api = YoutubeAPI.build_public()
for subscription in Subscription.objects.all():
__synchronize_sub(subscription, yt_api)
log.info("Sync - checking for videos to download") # Sync subscribed playlists/channels
downloader_process_all() log.info("Sync - checking videos")
yt_api = YoutubeAPI.build_public()
for subscription in Subscription.objects.all():
__check_new_videos_sub(subscription, yt_api)
__detect_deleted(subscription)
log.info("Sync - fetching missing thumbnails") log.info("Sync - checking for videos to download")
__fetch_thumbnails() downloader_process_all()
log.info("Synchronization finished.") log.info("Sync - fetching missing thumbnails")
__fetch_thumbnails()
log.info("Synchronization finished.")
finally:
__lock.release()
def schedule_synchronize(): def synchronize_subscription(subscription: Subscription):
__lock.acquire()
try:
log.info("Running synchronization for single subscription %d [%s]", subscription.id, subscription.name)
yt_api = YoutubeAPI.build_public()
log.info("Sync - checking videos")
__check_new_videos_sub(subscription, yt_api)
__detect_deleted(subscription)
log.info("Sync - checking for videos to download")
downloader_process_subscription(subscription)
log.info("Sync - fetching missing thumbnails")
__fetch_thumbnails()
log.info("Synchronization finished for subscription %d [%s].", subscription.id, subscription.name)
finally:
__lock.release()
def schedule_synchronize_global():
trigger = CronTrigger.from_crontab(settings.get('global', 'SynchronizationSchedule')) trigger = CronTrigger.from_crontab(settings.get('global', 'SynchronizationSchedule'))
scheduler.instance.add_job(synchronize, trigger, max_instances=1) scheduler.instance.add_job(synchronize, trigger, max_instances=1, coalesce=True)
def schedule_synchronize_now():
scheduler.instance.add_job(synchronize, max_instances=1, coalesce=True)
def schedule_synchronize_now_subscription(subscription: Subscription):
scheduler.instance.add_job(synchronize_subscription, args=[subscription])

View File

@ -1,5 +1,6 @@
import logging import logging
from typing import Callable, Union, Any, Optional from typing import Callable, Union, Any, Optional
import os
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -282,5 +283,18 @@ class Video(models.Model):
views = models.IntegerField(null=False, default=0) views = models.IntegerField(null=False, default=0)
rating = models.FloatField(null=False, default=0.5) rating = models.FloatField(null=False, default=0.5)
def mark_watched(self):
self.watched = True
def mark_unwatched(self):
self.watched = False
def get_files(self):
if self.downloaded_path is not None:
directory, file_pattern = os.path.split(self.downloaded_path)
for file in os.listdir(directory):
if file.startswith(file_pattern):
yield os.path.join(directory, file)
def __str__(self): def __str__(self):
return self.name return self.name

View File

@ -1,4 +1,5 @@
import logging import logging
import sys
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from .appconfig import settings from .appconfig import settings
@ -15,6 +16,9 @@ def initialize_scheduler():
'max_workers': settings.getint('global', 'SchedulerConcurrency') 'max_workers': settings.getint('global', 'SchedulerConcurrency')
} }
} }
job_defaults = {
'misfire_grace_time': sys.maxsize
}
instance = BackgroundScheduler(logger=logger, executors=executors) instance = BackgroundScheduler(logger=logger, executors=executors, job_defaults=job_defaults)
instance.start() instance.start()

View File

@ -1,3 +1,15 @@
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 2rem;
line-height: 2rem;
padding: 0rem 1rem;
display: flex;
align-content: center;
font-size: 10pt; }
/* Loading animation */ /* Loading animation */
.loading-dual-ring { .loading-dual-ring {
display: inline-block; display: inline-block;
@ -81,9 +93,9 @@
.video-gallery .card .card-more:hover { .video-gallery .card .card-more:hover {
text-decoration: none; } text-decoration: none; }
.video-gallery .video-icon-yes { .video-gallery .video-icon-yes {
color: #6c757d; } color: #007bff; }
.video-gallery .video-icon-no { .video-gallery .video-icon-no {
color: #cccccc; } color: #dddddd; }
.alert-card { .alert-card {
max-width: 35rem; max-width: 35rem;

View File

@ -1,6 +1,6 @@
{ {
"version": 3, "version": 3,
"mappings": "AAoBA,uBAAuB;AACvB,kBAAmB;EAlBf,OAAO,EAAE,YAAY;EACrB,KAAK,EAAE,IAAa;EACpB,MAAM,EAAE,IAAa;EAErB,wBAAQ;IACJ,OAAO,EAAE,GAAG;IACZ,OAAO,EAAE,KAAK;IACd,KAAK,EAAE,IAAa;IACpB,MAAM,EAAE,IAAa;IACrB,MAAM,EAAE,GAAG;IACX,aAAa,EAAE,GAAG;IAClB,MAAM,EAAE,iBAAkC;IAC1C,YAAY,EAAE,uCAAmD;IACjE,SAAS,EAAE,sCAAsC;;AASzD,wBAAyB;EAtBrB,OAAO,EAAE,YAAY;EACrB,KAAK,EAAE,IAAa;EACpB,MAAM,EAAE,IAAa;EAErB,8BAAQ;IACJ,OAAO,EAAE,GAAG;IACZ,OAAO,EAAE,KAAK;IACd,KAAK,EAAE,IAAa;IACpB,MAAM,EAAE,IAAa;IACrB,MAAM,EAAE,GAAG;IACX,aAAa,EAAE,GAAG;IAClB,MAAM,EAAE,mBAAkC;IAC1C,YAAY,EAAE,uCAAmD;IACjE,SAAS,EAAE,sCAAsC;;AAazD,4BAOC;EANG,EAAG;IACC,SAAS,EAAE,YAAY;EAE3B,IAAK;IACD,SAAS,EAAE,cAAc;AAIjC,gCAAiC;EAC7B,QAAQ,EAAE,KAAK;EACf,GAAG,EAAE,GAAG;EACR,IAAI,EAAE,GAAG;EACT,UAAU,EAAE,KAAK;EACjB,WAAW,EAAE,KAAK;;AAGtB,cAAe;EACX,QAAQ,EAAE,KAAK;EAAE,oCAAoC;EACrD,OAAO,EAAE,IAAI;EAAE,uBAAuB;EACtC,KAAK,EAAE,IAAI;EAAE,uCAAuC;EACpD,MAAM,EAAE,IAAI;EAAE,wCAAwC;EACtD,GAAG,EAAE,CAAC;EACN,IAAI,EAAE,CAAC;EACP,KAAK,EAAE,CAAC;EACR,MAAM,EAAE,CAAC;EACT,gBAAgB,EAAE,kBAAe;EAAE,mCAAmC;EACtE,OAAO,EAAE,CAAC;EAAE,qFAAqF;EACjG,MAAM,EAAE,OAAO;EAAE,4BAA4B;;AAI7C,4BAAc;EACV,OAAO,EAAE,MAAM;EACf,aAAa,EAAE,KAAK;AAGpB,+BAAW;EACP,OAAO,EAAE,MAAM;AAEnB,+BAAW;EACP,SAAS,EAAE,IAAI;EACf,aAAa,EAAE,KAAK;AAExB,gCAAY;EACR,SAAS,EAAE,IAAI;EACf,aAAa,EAAE,KAAK;EAEpB,uCAAO;IACH,SAAS,EAAE,GAAG;AAGtB,iCAAa;EACT,OAAO,EAAE,YAAY;AAGzB,+BAAW;EACP,YAAY,EAAE,QAAQ;EACtB,qCAAQ;IACJ,eAAe,EAAE,IAAI;AASjC,8BAAgB;EACZ,KAAK,EAAE,OAAO;AAElB,6BAAe;EACX,KAAK,EAAE,OAAO;;AAItB,WAAY;EACR,SAAS,EAAE,KAAK;EAChB,MAAM,EAAE,MAAM;;AAId,2BAAe;EACX,OAAO,EAAE,IAAI;;AAIrB,kBAAmB;EACf,MAAM,EAAE,QAAQ;EAChB,OAAO,EAAE,QAAQ;EAEjB,qBAAG;IACC,MAAM,EAAE,CAAC", "mappings": "AAEA,OAAQ;EACJ,QAAQ,EAAE,KAAK;EACf,IAAI,EAAE,CAAC;EACP,KAAK,EAAE,CAAC;EACR,MAAM,EAAE,CAAC;EACT,MAAM,EAAE,IAAI;EACZ,WAAW,EAAE,IAAI;EACjB,OAAO,EAAE,SAAS;EAClB,OAAO,EAAE,IAAI;EACb,aAAa,EAAE,MAAM;EACrB,SAAS,EAAE,IAAI;;AAqBnB,uBAAuB;AACvB,kBAAmB;EAlBf,OAAO,EAAE,YAAY;EACrB,KAAK,EAAE,IAAa;EACpB,MAAM,EAAE,IAAa;EAErB,wBAAQ;IACJ,OAAO,EAAE,GAAG;IACZ,OAAO,EAAE,KAAK;IACd,KAAK,EAAE,IAAa;IACpB,MAAM,EAAE,IAAa;IACrB,MAAM,EAAE,GAAG;IACX,aAAa,EAAE,GAAG;IAClB,MAAM,EAAE,iBAAkC;IAC1C,YAAY,EAAE,uCAAmD;IACjE,SAAS,EAAE,sCAAsC;;AASzD,wBAAyB;EAtBrB,OAAO,EAAE,YAAY;EACrB,KAAK,EAAE,IAAa;EACpB,MAAM,EAAE,IAAa;EAErB,8BAAQ;IACJ,OAAO,EAAE,GAAG;IACZ,OAAO,EAAE,KAAK;IACd,KAAK,EAAE,IAAa;IACpB,MAAM,EAAE,IAAa;IACrB,MAAM,EAAE,GAAG;IACX,aAAa,EAAE,GAAG;IAClB,MAAM,EAAE,mBAAkC;IAC1C,YAAY,EAAE,uCAAmD;IACjE,SAAS,EAAE,sCAAsC;;AAazD,4BAOC;EANG,EAAG;IACC,SAAS,EAAE,YAAY;EAE3B,IAAK;IACD,SAAS,EAAE,cAAc;AAIjC,gCAAiC;EAC7B,QAAQ,EAAE,KAAK;EACf,GAAG,EAAE,GAAG;EACR,IAAI,EAAE,GAAG;EACT,UAAU,EAAE,KAAK;EACjB,WAAW,EAAE,KAAK;;AAGtB,cAAe;EACX,QAAQ,EAAE,KAAK;EAAE,oCAAoC;EACrD,OAAO,EAAE,IAAI;EAAE,uBAAuB;EACtC,KAAK,EAAE,IAAI;EAAE,uCAAuC;EACpD,MAAM,EAAE,IAAI;EAAE,wCAAwC;EACtD,GAAG,EAAE,CAAC;EACN,IAAI,EAAE,CAAC;EACP,KAAK,EAAE,CAAC;EACR,MAAM,EAAE,CAAC;EACT,gBAAgB,EAAE,kBAAe;EAAE,mCAAmC;EACtE,OAAO,EAAE,CAAC;EAAE,qFAAqF;EACjG,MAAM,EAAE,OAAO;EAAE,4BAA4B;;AAI7C,4BAAc;EACV,OAAO,EAAE,MAAM;EACf,aAAa,EAAE,KAAK;AAGpB,+BAAW;EACP,OAAO,EAAE,MAAM;AAEnB,+BAAW;EACP,SAAS,EAAE,IAAI;EACf,aAAa,EAAE,KAAK;AAExB,gCAAY;EACR,SAAS,EAAE,IAAI;EACf,aAAa,EAAE,KAAK;EAEpB,uCAAO;IACH,SAAS,EAAE,GAAG;AAGtB,iCAAa;EACT,OAAO,EAAE,YAAY;AAGzB,+BAAW;EACP,YAAY,EAAE,QAAQ;EACtB,qCAAQ;IACJ,eAAe,EAAE,IAAI;AASjC,8BAAgB;EACZ,KAAK,EA/GE,OAAO;AAiHlB,6BAAe;EACX,KAAK,EAAE,OAAO;;AAItB,WAAY;EACR,SAAS,EAAE,KAAK;EAChB,MAAM,EAAE,MAAM;;AAId,2BAAe;EACX,OAAO,EAAE,IAAI;;AAIrB,kBAAmB;EACf,MAAM,EAAE,QAAQ;EAChB,OAAO,EAAE,QAAQ;EAEjB,qBAAG;IACC,MAAM,EAAE,CAAC",
"sources": ["style.scss"], "sources": ["style.scss"],
"names": [], "names": [],
"file": "style.css" "file": "style.css"

View File

@ -1,5 +1,18 @@
$accent-color: #007bff; $accent-color: #007bff;
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 2rem;
line-height: 2rem;
padding: 0rem 1rem;
display: flex;
align-content: center;
font-size: 10pt;
}
@mixin loading-dual-ring($scale : 1) { @mixin loading-dual-ring($scale : 1) {
display: inline-block; display: inline-block;
width: $scale * 64px; width: $scale * 64px;
@ -96,10 +109,10 @@ $accent-color: #007bff;
} }
.video-icon-yes { .video-icon-yes {
color: #6c757d; color: $accent-color;
} }
.video-icon-no { .video-icon-no {
color: #cccccc; color: #dddddd;
} }
} }

View File

@ -21,13 +21,28 @@
<small class="text-muted">{{ video.publish_date }}</small> <small class="text-muted">{{ video.publish_date }}</small>
<a class="card-more float-right text-muted" <a class="card-more float-right text-muted"
href="#" role="button" id="dropdownMenuLink" href="#" role="button" id="dropdownMenuLink"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"><span class="typcn typcn-cog"></span></a> data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="typcn typcn-cog"></span>
</a>
<div class="dropdown-menu" aria-labelledby="dropdownMenuLink"> <div class="dropdown-menu" aria-labelledby="dropdownMenuLink">
<a class="dropdown-item" href="#">Mark {{ video.watched | yesno:"not watched,watched" }}</a> {% if video.watched %}
{% if video.downloaded_path %} <a class="dropdown-item ajax-link" href="#" data-post-url="{% url 'ajax_action_mark_video_unwatched' video.id %}">
<a class="dropdown-item" href="#">Delete downloaded</a> Mark not watched
</a>
{% else %} {% else %}
<a class="dropdown-item" href="#">Download</a> <a class="dropdown-item ajax-link" href="#" data-post-url="{% url 'ajax_action_mark_video_watched' video.id %}">
Mark watched
</a>
{% endif %}
{% if video.downloaded_path %}
<a class="dropdown-item ajax-link" href="#" data-post-url="{% url 'ajax_action_delete_video_files' video.id %}">
Delete downloaded
</a>
{% else %}
<a class="dropdown-item ajax-link" href="#" data-post-url="{% url 'ajax_action_download_video_files' video.id %}" >
Download
</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@ -152,3 +152,34 @@ class AjaxModal
}); });
} }
} }
function syncNow() {
$.post("{% url 'ajax_action_sync_now' %}", {
csrfmiddlewaretoken: '{{ csrf_token }}'
});
}
function ajaxLink_Clicked() {
let url_post = $(this).data('post-url');
let url_get = $(this).data('get-url');
if (url_post != null) {
$.post(url_post, {
csrfmiddlewaretoken: '{{ csrf_token }}'
});
}
else if (url_get != null) {
$.get(url_get, {
csrfmiddlewaretoken: '{{ csrf_token }}'
});
}
}
///
/// Initialization
///
$(document).ready(function ()
{
$(".ajax-link").on("click", ajaxLink_Clicked);
$("#btn_sync_now").on("click", syncNow);
});

View File

@ -130,7 +130,7 @@ function videos_ReloadWithTimer()
{ {
videos_Submit.call($('#form_video_filter')); videos_Submit.call($('#form_video_filter'));
videos_timeout = null; videos_timeout = null;
}, 300); }, 200);
} }
function videos_Submit(e) function videos_Submit(e)
@ -144,6 +144,7 @@ function videos_Submit(e)
$.post(url, form.serialize()) $.post(url, form.serialize())
.done(function(result) { .done(function(result) {
$("#videos-wrapper").html(result); $("#videos-wrapper").html(result);
$(".ajax-link").on("click", ajaxLink_Clicked);
}) })
.fail(function() { .fail(function() {
$("#videos-wrapper").html('<div class="alert alert-danger">An error occurred while retrieving the video list!</div>'); $("#videos-wrapper").html('<div class="alert alert-danger">An error occurred while retrieving the video list!</div>');
@ -184,4 +185,5 @@ $(document).ready(function ()
filters_form.find('select[name=sort]').on('change', videos_ReloadWithTimer); filters_form.find('select[name=sort]').on('change', videos_ReloadWithTimer);
filters_form.find('select[name=show_watched]').on('change', videos_ReloadWithTimer); filters_form.find('select[name=show_watched]').on('change', videos_ReloadWithTimer);
filters_form.find('select[name=show_downloaded]').on('change', videos_ReloadWithTimer); filters_form.find('select[name=show_downloaded]').on('change', videos_ReloadWithTimer);
videos_Reload();
}); });

View File

@ -65,5 +65,12 @@
{% block body %} {% block body %}
{% endblock %} {% endblock %}
</div> </div>
<footer class="footer bg-light">
<span class="ml-auto text-muted">Last synchronized: just now</span>
<button id="btn_sync_now" class="btn btn-sm btn-light" title="Synchronize now!">
<span class="typcn typcn-arrow-sync" aria-hidden="true"></span>
</button>
</footer>
</body> </body>
</html> </html>

View File

@ -21,6 +21,8 @@ from django.urls import path
from .views.auth import ExtendedLoginView, RegisterView, RegisterDoneView from .views.auth import ExtendedLoginView, RegisterView, RegisterDoneView
from .views.index import index, ajax_get_tree, ajax_get_videos, CreateFolderModal, UpdateFolderModal, DeleteFolderModal,\ from .views.index import index, ajax_get_tree, ajax_get_videos, CreateFolderModal, UpdateFolderModal, DeleteFolderModal,\
CreateSubscriptionModal, UpdateSubscriptionModal, DeleteSubscriptionModal CreateSubscriptionModal, UpdateSubscriptionModal, DeleteSubscriptionModal
from .views.actions import SyncNowView, DeleteVideoFilesView, DownloadVideoFilesView, MarkVideoWatchedView, \
MarkVideoUnwatchedView
from .views import old_views from .views import old_views
urlpatterns = [ urlpatterns = [
@ -31,6 +33,12 @@ urlpatterns = [
path('', include('django.contrib.auth.urls')), path('', include('django.contrib.auth.urls')),
# Ajax # Ajax
path('ajax/action/sync_now/', SyncNowView.as_view(), name='ajax_action_sync_now'),
path('ajax/action/delete_video_files/<int:pk>', DeleteVideoFilesView.as_view(), name='ajax_action_delete_video_files'),
path('ajax/action/download_video_files/<int:pk>', DownloadVideoFilesView.as_view(), name='ajax_action_download_video_files'),
path('ajax/action/mark_video_watched/<int:pk>', MarkVideoWatchedView.as_view(), name='ajax_action_mark_video_watched'),
path('ajax/action/mark_video_unwatched/<int:pk>', MarkVideoUnwatchedView.as_view(), name='ajax_action_mark_video_unwatched'),
path('ajax/get_tree/', ajax_get_tree, name='ajax_get_tree'), path('ajax/get_tree/', ajax_get_tree, name='ajax_get_tree'),
path('ajax/get_videos/', ajax_get_videos, name='ajax_get_videos'), path('ajax/get_videos/', ajax_get_videos, name='ajax_get_videos'),

View File

@ -110,5 +110,5 @@ class ConfigParserWithEnv(ConfigParser):
:param kwargs: :param kwargs:
:return: :return:
""" """
if isinstance(super()._interpolation, ExtendedInterpolatorWithEnv): if isinstance(self._interpolation, ExtendedInterpolatorWithEnv):
super()._interpolation.set_additional_options(**kwargs) self._interpolation.set_additional_options(**kwargs)

View File

@ -0,0 +1,61 @@
from django.http import HttpRequest, HttpResponseBadRequest, JsonResponse
from django.shortcuts import render
from django import forms
from django.views.generic import CreateView, UpdateView, DeleteView, View
from django.views.generic.edit import FormMixin
from YtManagerApp.management.videos import get_videos
from YtManagerApp.models import Subscription, SubscriptionFolder, Video
from YtManagerApp.views.controls.modal import ModalMixin
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Div, HTML
from django.db.models import Q
from YtManagerApp.utils import youtube
from YtManagerApp.management.jobs.synchronize import schedule_synchronize_now
from YtManagerApp.management.jobs.delete_video import schedule_delete_video
from YtManagerApp.management.jobs.download_video import schedule_download_video
class SyncNowView(View):
def post(self, *args, **kwargs):
schedule_synchronize_now()
return JsonResponse({
'success': True
})
class DeleteVideoFilesView(View):
def post(self, *args, **kwargs):
video = Video.objects.get(id=kwargs['pk'])
schedule_delete_video(video)
return JsonResponse({
'success': True
})
class DownloadVideoFilesView(View):
def post(self, *args, **kwargs):
video = Video.objects.get(id=kwargs['pk'])
schedule_download_video(video)
return JsonResponse({
'success': True
})
class MarkVideoWatchedView(View):
def post(self, *args, **kwargs):
video = Video.objects.get(id=kwargs['pk'])
video.watched = True
video.save()
return JsonResponse({
'success': True
})
class MarkVideoUnwatchedView(View):
def post(self, *args, **kwargs):
video = Video.objects.get(id=kwargs['pk'])
video.watched = False
video.save()
return JsonResponse({
'success': True
})

View File

@ -1,15 +1,17 @@
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, HTML
from django import forms
from django.db.models import Q
from django.http import HttpRequest, HttpResponseBadRequest, JsonResponse from django.http import HttpRequest, HttpResponseBadRequest, JsonResponse
from django.shortcuts import render from django.shortcuts import render
from django import forms
from django.views.generic import CreateView, UpdateView, DeleteView from django.views.generic import CreateView, UpdateView, DeleteView
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
from YtManagerApp.management.videos import get_videos from YtManagerApp.management.videos import get_videos
from YtManagerApp.models import Subscription, SubscriptionFolder from YtManagerApp.models import Subscription, SubscriptionFolder
from YtManagerApp.views.controls.modal import ModalMixin
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Div, HTML
from django.db.models import Q
from YtManagerApp.utils import youtube from YtManagerApp.utils import youtube
from YtManagerApp.views.controls.modal import ModalMixin
class VideoFilterForm(forms.Form): class VideoFilterForm(forms.Form):
CHOICES_SORT = ( CHOICES_SORT = (
@ -276,15 +278,20 @@ class CreateSubscriptionModal(ModalMixin, CreateView):
try: try:
form.instance.fetch_from_url(form.cleaned_data['playlist_url'], api) form.instance.fetch_from_url(form.cleaned_data['playlist_url'], api)
except youtube.YoutubeChannelNotFoundException: except youtube.YoutubeChannelNotFoundException:
return self.modal_response(form, False, 'Could not find a channel based on the given URL. Please verify that the URL is correct.') return self.modal_response(
form, False, 'Could not find a channel based on the given URL. Please verify that the URL is correct.')
except youtube.YoutubeUserNotFoundException: except youtube.YoutubeUserNotFoundException:
return self.modal_response(form, False, 'Could not find an user based on the given URL. Please verify that the URL is correct.') return self.modal_response(
form, False, 'Could not find an user based on the given URL. Please verify that the URL is correct.')
except youtube.YoutubePlaylistNotFoundException: except youtube.YoutubePlaylistNotFoundException:
return self.modal_response(form, False, 'Could not find a playlist based on the given URL. Please verify that the URL is correct.') return self.modal_response(
form, False, 'Could not find a playlist based on the given URL. Please verify that the URL is correct.')
except youtube.YoutubeException as e: except youtube.YoutubeException as e:
return self.modal_response(form, False, str(e)) return self.modal_response(
form, False, str(e))
except youtube.APIError as e: except youtube.APIError as e:
return self.modal_response(form, False, 'An error occurred while communicating with the YouTube API: ' + str(e)) return self.modal_response(
form, False, 'An error occurred while communicating with the YouTube API: ' + str(e))
return super().form_valid(form) return super().form_valid(form)
@ -292,7 +299,8 @@ class CreateSubscriptionModal(ModalMixin, CreateView):
class UpdateSubscriptionForm(forms.ModelForm): class UpdateSubscriptionForm(forms.ModelForm):
class Meta: class Meta:
model = Subscription model = Subscription
fields = ['name', 'parent_folder', 'auto_download', 'download_limit', 'download_order', 'manager_delete_after_watched'] fields = ['name', 'parent_folder', 'auto_download',
'download_limit', 'download_order', 'manager_delete_after_watched']
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)