diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 557fc56..d85d4a9 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -2,11 +2,26 @@ - + + - + + + + + + + + + + + - + + + + + - + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + - + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - @@ -278,15 +302,13 @@ - subscriptionEditDialog_ - folder_edit_dialog folderEditDialog_ treeNode_Edit default_app_config @@ -315,6 +337,8 @@ ajax_index_get_videos ajax_index_get_tree self.helper + _interpolation + csrf loading @@ -337,15 +361,6 @@ @@ -403,10 +427,10 @@ - @@ -489,6 +513,13 @@ + + + + + + + @@ -523,22 +554,6 @@ - - - - - - - - - - - - - - - - @@ -571,6 +586,7 @@ + @@ -678,7 +694,7 @@ + + + + + + + + + - + - - + + - + @@ -739,7 +764,7 @@ - + @@ -777,176 +802,15 @@ - file://$PROJECT_DIR$/YtManagerApp/views/index.py - 145 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -964,17 +828,6 @@ - - - - - - - - - - - @@ -990,13 +843,6 @@ - - - - - - - @@ -1011,16 +857,6 @@ - - - - - - - - - - @@ -1028,16 +864,6 @@ - - - - - - - - - - @@ -1056,22 +882,6 @@ - - - - - - - - - - - - - - - - @@ -1079,34 +889,56 @@ - - - - - - + + - + + + + - - + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1117,30 +949,284 @@ - - - - + - + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/YtManagerApp/appmain.py b/YtManagerApp/appmain.py index 9661aba..ad7b771 100644 --- a/YtManagerApp/appmain.py +++ b/YtManagerApp/appmain.py @@ -1,11 +1,11 @@ from .appconfig import initialize_app_config from .scheduler import initialize_scheduler -from .management.jobs.synchronize import schedule_synchronize +from .management.jobs.synchronize import schedule_synchronize_global import logging def main(): initialize_app_config() initialize_scheduler() - schedule_synchronize() + schedule_synchronize_global() logging.info('Initialization complete.') diff --git a/YtManagerApp/apps.py b/YtManagerApp/apps.py index 72c7fb4..3d7cd30 100644 --- a/YtManagerApp/apps.py +++ b/YtManagerApp/apps.py @@ -6,8 +6,6 @@ class YtManagerAppConfig(AppConfig): name = 'YtManagerApp' def ready(self): - # There seems to be a problem related to the auto-reload functionality where ready() is called twice - # (in different processes). This seems like a good enough workaround (other than --noreload). - if not os.getenv('RUN_MAIN', False): - from .appmain import main - main() + # Run server using --noreload to avoid having the scheduler run on 2 different processes + from .appmain import main + main() diff --git a/YtManagerApp/management/downloader.py b/YtManagerApp/management/downloader.py index 23dcdce..d1d2f73 100644 --- a/YtManagerApp/management/downloader.py +++ b/YtManagerApp/management/downloader.py @@ -35,7 +35,7 @@ def __get_subscription_config(sub: Subscription): 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) enabled, global_limit, limit, order = __get_subscription_config(sub) @@ -70,7 +70,7 @@ def __process_subscription(sub: Subscription): def downloader_process_all(): for subscription in Subscription.objects.all(): - __process_subscription(subscription) + downloader_process_subscription(subscription) def fetch_thumbnail(url, object_type, identifier, quality): diff --git a/YtManagerApp/management/jobs/delete_video.py b/YtManagerApp/management/jobs/delete_video.py new file mode 100644 index 0000000..19057d6 --- /dev/null +++ b/YtManagerApp/management/jobs/delete_video.py @@ -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]) diff --git a/YtManagerApp/management/jobs/download_video.py b/YtManagerApp/management/jobs/download_video.py index 4307c8b..cff7059 100644 --- a/YtManagerApp/management/jobs/download_video.py +++ b/YtManagerApp/management/jobs/download_video.py @@ -4,11 +4,24 @@ from YtManagerApp.appconfig import get_user_config import os import youtube_dl import logging +import re log = logging.getLogger('video_downloader') 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): # resolve path format_dict = { @@ -24,7 +37,7 @@ def __build_youtube_dl_params(video: Video, user_config): user_config.set_additional_interpolation_options(**format_dict) 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.normpath(output_path) @@ -39,7 +52,7 @@ def __build_youtube_dl_params(video: Video, user_config): 'allsubtitles': user_config.getboolean('user', 'DownloadSubtitlesAll'), 'postprocessors': [ { - 'key': 'FFmpegMetadataPP' + 'key': 'FFmpegMetadata' }, ] } @@ -72,7 +85,7 @@ def download_video(video: Video, attempt: int = 1): if ret == 0: video.downloaded_path = output_path 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: log.warning('Re-enqueueing video (attempt %d/%d)', attempt, max_attempts) diff --git a/YtManagerApp/management/jobs/synchronize.py b/YtManagerApp/management/jobs/synchronize.py index dddac89..8c1f60f 100644 --- a/YtManagerApp/management/jobs/synchronize.py +++ b/YtManagerApp/management/jobs/synchronize.py @@ -1,24 +1,67 @@ -import logging - from apscheduler.triggers.cron import CronTrigger +from threading import Lock +import os +import errno +import mimetypes -from YtManagerApp.appconfig import settings -from YtManagerApp.management.downloader import fetch_thumbnail, downloader_process_all +from YtManagerApp import scheduler +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.models import * -from YtManagerApp import scheduler from YtManagerApp.utils.youtube import YoutubeAPI 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 for video in yt_api.list_playlist_videos(subscription.playlist_id): results = Video.objects.filter(video_id=video.getVideoId(), subscription=subscription) 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) + 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): @@ -46,23 +89,63 @@ def __fetch_thumbnails(): 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 - log.info("Sync - checking for new videos") - yt_api = YoutubeAPI.build_public() - for subscription in Subscription.objects.all(): - __synchronize_sub(subscription, yt_api) + try: + log.info("Running scheduled synchronization... ") - log.info("Sync - checking for videos to download") - downloader_process_all() + # Sync subscribed playlists/channels + 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") - __fetch_thumbnails() + log.info("Sync - checking for videos to download") + 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')) - 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]) diff --git a/YtManagerApp/models.py b/YtManagerApp/models.py index 2939b1a..0178118 100644 --- a/YtManagerApp/models.py +++ b/YtManagerApp/models.py @@ -1,5 +1,6 @@ import logging from typing import Callable, Union, Any, Optional +import os 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) 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): return self.name \ No newline at end of file diff --git a/YtManagerApp/scheduler.py b/YtManagerApp/scheduler.py index 0a21bf5..8a846ab 100644 --- a/YtManagerApp/scheduler.py +++ b/YtManagerApp/scheduler.py @@ -1,4 +1,5 @@ import logging +import sys from apscheduler.schedulers.background import BackgroundScheduler from .appconfig import settings @@ -15,6 +16,9 @@ def initialize_scheduler(): '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() diff --git a/YtManagerApp/static/YtManagerApp/css/style.css b/YtManagerApp/static/YtManagerApp/css/style.css index 74e3624..c3878fc 100644 --- a/YtManagerApp/static/YtManagerApp/css/style.css +++ b/YtManagerApp/static/YtManagerApp/css/style.css @@ -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-dual-ring { display: inline-block; @@ -81,9 +93,9 @@ .video-gallery .card .card-more:hover { text-decoration: none; } .video-gallery .video-icon-yes { - color: #6c757d; } + color: #007bff; } .video-gallery .video-icon-no { - color: #cccccc; } + color: #dddddd; } .alert-card { max-width: 35rem; diff --git a/YtManagerApp/static/YtManagerApp/css/style.css.map b/YtManagerApp/static/YtManagerApp/css/style.css.map index f183181..4f3daaf 100644 --- a/YtManagerApp/static/YtManagerApp/css/style.css.map +++ b/YtManagerApp/static/YtManagerApp/css/style.css.map @@ -1,6 +1,6 @@ { "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"], "names": [], "file": "style.css" diff --git a/YtManagerApp/static/YtManagerApp/css/style.scss b/YtManagerApp/static/YtManagerApp/css/style.scss index 7166be0..8d25464 100644 --- a/YtManagerApp/static/YtManagerApp/css/style.scss +++ b/YtManagerApp/static/YtManagerApp/css/style.scss @@ -1,5 +1,18 @@ $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) { display: inline-block; width: $scale * 64px; @@ -96,10 +109,10 @@ $accent-color: #007bff; } .video-icon-yes { - color: #6c757d; + color: $accent-color; } .video-icon-no { - color: #cccccc; + color: #dddddd; } } diff --git a/YtManagerApp/templates/YtManagerApp/index_videos.html b/YtManagerApp/templates/YtManagerApp/index_videos.html index 5748923..4ec153f 100644 --- a/YtManagerApp/templates/YtManagerApp/index_videos.html +++ b/YtManagerApp/templates/YtManagerApp/index_videos.html @@ -21,13 +21,28 @@ {{ video.publish_date }} + data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> + + diff --git a/YtManagerApp/templates/YtManagerApp/js/common.js b/YtManagerApp/templates/YtManagerApp/js/common.js index 274e0a9..d0fe045 100644 --- a/YtManagerApp/templates/YtManagerApp/js/common.js +++ b/YtManagerApp/templates/YtManagerApp/js/common.js @@ -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); +}); \ No newline at end of file diff --git a/YtManagerApp/templates/YtManagerApp/js/index.js b/YtManagerApp/templates/YtManagerApp/js/index.js index 2d70f67..6fe9cfd 100644 --- a/YtManagerApp/templates/YtManagerApp/js/index.js +++ b/YtManagerApp/templates/YtManagerApp/js/index.js @@ -130,7 +130,7 @@ function videos_ReloadWithTimer() { videos_Submit.call($('#form_video_filter')); videos_timeout = null; - }, 300); + }, 200); } function videos_Submit(e) @@ -144,6 +144,7 @@ function videos_Submit(e) $.post(url, form.serialize()) .done(function(result) { $("#videos-wrapper").html(result); + $(".ajax-link").on("click", ajaxLink_Clicked); }) .fail(function() { $("#videos-wrapper").html('
An error occurred while retrieving the video list!
'); @@ -184,4 +185,5 @@ $(document).ready(function () 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_downloaded]').on('change', videos_ReloadWithTimer); + videos_Reload(); }); diff --git a/YtManagerApp/templates/YtManagerApp/master_default.html b/YtManagerApp/templates/YtManagerApp/master_default.html index f7d1cdf..077f81b 100644 --- a/YtManagerApp/templates/YtManagerApp/master_default.html +++ b/YtManagerApp/templates/YtManagerApp/master_default.html @@ -65,5 +65,12 @@ {% block body %} {% endblock %} + +
+ Last synchronized: just now + +
\ No newline at end of file diff --git a/YtManagerApp/urls.py b/YtManagerApp/urls.py index ec2216b..21c72c8 100644 --- a/YtManagerApp/urls.py +++ b/YtManagerApp/urls.py @@ -21,6 +21,8 @@ from django.urls import path from .views.auth import ExtendedLoginView, RegisterView, RegisterDoneView from .views.index import index, ajax_get_tree, ajax_get_videos, CreateFolderModal, UpdateFolderModal, DeleteFolderModal,\ CreateSubscriptionModal, UpdateSubscriptionModal, DeleteSubscriptionModal +from .views.actions import SyncNowView, DeleteVideoFilesView, DownloadVideoFilesView, MarkVideoWatchedView, \ + MarkVideoUnwatchedView from .views import old_views urlpatterns = [ @@ -31,6 +33,12 @@ urlpatterns = [ path('', include('django.contrib.auth.urls')), # Ajax + path('ajax/action/sync_now/', SyncNowView.as_view(), name='ajax_action_sync_now'), + path('ajax/action/delete_video_files/', DeleteVideoFilesView.as_view(), name='ajax_action_delete_video_files'), + path('ajax/action/download_video_files/', DownloadVideoFilesView.as_view(), name='ajax_action_download_video_files'), + path('ajax/action/mark_video_watched/', MarkVideoWatchedView.as_view(), name='ajax_action_mark_video_watched'), + path('ajax/action/mark_video_unwatched/', MarkVideoUnwatchedView.as_view(), name='ajax_action_mark_video_unwatched'), + path('ajax/get_tree/', ajax_get_tree, name='ajax_get_tree'), path('ajax/get_videos/', ajax_get_videos, name='ajax_get_videos'), diff --git a/YtManagerApp/utils/customconfigparser.py b/YtManagerApp/utils/customconfigparser.py index ebc0706..2828e18 100644 --- a/YtManagerApp/utils/customconfigparser.py +++ b/YtManagerApp/utils/customconfigparser.py @@ -110,5 +110,5 @@ class ConfigParserWithEnv(ConfigParser): :param kwargs: :return: """ - if isinstance(super()._interpolation, ExtendedInterpolatorWithEnv): - super()._interpolation.set_additional_options(**kwargs) + if isinstance(self._interpolation, ExtendedInterpolatorWithEnv): + self._interpolation.set_additional_options(**kwargs) diff --git a/YtManagerApp/views/actions.py b/YtManagerApp/views/actions.py new file mode 100644 index 0000000..6e120b6 --- /dev/null +++ b/YtManagerApp/views/actions.py @@ -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 + }) diff --git a/YtManagerApp/views/index.py b/YtManagerApp/views/index.py index b613728..3f6a08b 100644 --- a/YtManagerApp/views/index.py +++ b/YtManagerApp/views/index.py @@ -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.shortcuts import render -from django import forms from django.views.generic import CreateView, UpdateView, DeleteView from django.views.generic.edit import FormMixin + from YtManagerApp.management.videos import get_videos 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.views.controls.modal import ModalMixin + class VideoFilterForm(forms.Form): CHOICES_SORT = ( @@ -276,15 +278,20 @@ class CreateSubscriptionModal(ModalMixin, CreateView): try: form.instance.fetch_from_url(form.cleaned_data['playlist_url'], api) 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: - 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: - 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: - return self.modal_response(form, False, str(e)) + return self.modal_response( + form, False, str(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) @@ -292,7 +299,8 @@ class CreateSubscriptionModal(ModalMixin, CreateView): class UpdateSubscriptionForm(forms.ModelForm): class Meta: 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): super().__init__(*args, **kwargs)