mirror of
				https://github.com/chibicitiberiu/ytsm.git
				synced 2024-02-24 05:43:31 +00:00 
			
		
		
		
	Implemented video management facilities.
This commit is contained in:
		@@ -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.')
 | 
			
		||||
 
 | 
			
		||||
@@ -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()
 | 
			
		||||
 
 | 
			
		||||
@@ -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):
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										38
									
								
								YtManagerApp/management/jobs/delete_video.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								YtManagerApp/management/jobs/delete_video.py
									
									
									
									
									
										Normal 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])
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -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])
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
@@ -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()
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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"
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -21,13 +21,28 @@
 | 
			
		||||
                        <small class="text-muted">{{ video.publish_date }}</small>
 | 
			
		||||
                        <a class="card-more float-right text-muted"
 | 
			
		||||
                           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">
 | 
			
		||||
                            <a class="dropdown-item" href="#">Mark {{ video.watched | yesno:"not watched,watched" }}</a>
 | 
			
		||||
                            {% if video.downloaded_path %}
 | 
			
		||||
                                <a class="dropdown-item" href="#">Delete downloaded</a>
 | 
			
		||||
                            {% if video.watched %}
 | 
			
		||||
                                <a class="dropdown-item ajax-link" href="#" data-post-url="{% url 'ajax_action_mark_video_unwatched' video.id %}">
 | 
			
		||||
                                    Mark not watched
 | 
			
		||||
                                </a>
 | 
			
		||||
                            {% 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 %}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -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);
 | 
			
		||||
});
 | 
			
		||||
@@ -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('<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=show_watched]').on('change', videos_ReloadWithTimer);
 | 
			
		||||
    filters_form.find('select[name=show_downloaded]').on('change', videos_ReloadWithTimer);
 | 
			
		||||
    videos_Reload();
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -65,5 +65,12 @@
 | 
			
		||||
            {% block body %}
 | 
			
		||||
            {% endblock %}
 | 
			
		||||
        </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>
 | 
			
		||||
</html>
 | 
			
		||||
@@ -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/<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_videos/', ajax_get_videos, name='ajax_get_videos'),
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										61
									
								
								YtManagerApp/views/actions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								YtManagerApp/views/actions.py
									
									
									
									
									
										Normal 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
 | 
			
		||||
        })
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user