mirror of
				https://github.com/chibicitiberiu/ytsm.git
				synced 2024-02-24 05:43:31 +00:00 
			
		
		
		
	Added docker support
This commit is contained in:
		
							
								
								
									
										0
									
								
								app/YtManagerApp/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								app/YtManagerApp/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										6
									
								
								app/YtManagerApp/admin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								app/YtManagerApp/admin.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
from .models import SubscriptionFolder, Subscription, Video
 | 
			
		||||
 | 
			
		||||
admin.site.register(SubscriptionFolder)
 | 
			
		||||
admin.site.register(Subscription)
 | 
			
		||||
admin.site.register(Video)
 | 
			
		||||
							
								
								
									
										123
									
								
								app/YtManagerApp/appconfig.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								app/YtManagerApp/appconfig.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,123 @@
 | 
			
		||||
import logging
 | 
			
		||||
import os
 | 
			
		||||
import os.path
 | 
			
		||||
from collections import ChainMap
 | 
			
		||||
from configparser import ConfigParser
 | 
			
		||||
from shutil import copyfile
 | 
			
		||||
from typing import Optional, Any
 | 
			
		||||
 | 
			
		||||
from django.conf import settings as dj_settings
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
 | 
			
		||||
from .models import UserSettings, Subscription
 | 
			
		||||
from .utils.extended_interpolation_with_env import ExtendedInterpolatorWithEnv
 | 
			
		||||
 | 
			
		||||
_CONFIG_DIR = os.path.join(dj_settings.BASE_DIR, 'config')
 | 
			
		||||
_LOG_FILE = 'log.log'
 | 
			
		||||
_LOG_PATH = os.path.join(_CONFIG_DIR, _LOG_FILE)
 | 
			
		||||
_LOG_FORMAT = '%(asctime)s|%(process)d|%(thread)d|%(name)s|%(filename)s|%(lineno)d|%(levelname)s|%(message)s'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AppSettings(ConfigParser):
 | 
			
		||||
    _DEFAULT_INTERPOLATION = ExtendedInterpolatorWithEnv()
 | 
			
		||||
    __DEFAULTS_FILE = 'defaults.ini'
 | 
			
		||||
    __SETTINGS_FILE = 'config.ini'
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        super().__init__(allow_no_value=True, *args, **kwargs)
 | 
			
		||||
        self.__defaults_path = os.path.join(_CONFIG_DIR, AppSettings.__DEFAULTS_FILE)
 | 
			
		||||
        self.__settings_path = os.path.join(_CONFIG_DIR, AppSettings.__SETTINGS_FILE)
 | 
			
		||||
 | 
			
		||||
    def initialize(self):
 | 
			
		||||
        self.read([self.__defaults_path, self.__settings_path])
 | 
			
		||||
 | 
			
		||||
    def save(self):
 | 
			
		||||
        if os.path.exists(self.__settings_path):
 | 
			
		||||
            # Create a backup
 | 
			
		||||
            copyfile(self.__settings_path, self.__settings_path + ".backup")
 | 
			
		||||
        else:
 | 
			
		||||
            # Ensure directory exists
 | 
			
		||||
            settings_dir = os.path.dirname(self.__settings_path)
 | 
			
		||||
            os.makedirs(settings_dir, exist_ok=True)
 | 
			
		||||
 | 
			
		||||
        with open(self.__settings_path, 'w') as f:
 | 
			
		||||
            self.write(f)
 | 
			
		||||
 | 
			
		||||
    def __get_combined_dict(self, vars: Optional[Any], sub: Optional[Subscription], user: Optional[User]) -> ChainMap:
 | 
			
		||||
        vars_dict = {}
 | 
			
		||||
        sub_overloads_dict = {}
 | 
			
		||||
        user_settings_dict = {}
 | 
			
		||||
 | 
			
		||||
        if vars is not None:
 | 
			
		||||
            vars_dict = vars
 | 
			
		||||
 | 
			
		||||
        if sub is not None:
 | 
			
		||||
            sub_overloads_dict = sub.get_overloads_dict()
 | 
			
		||||
 | 
			
		||||
        if user is not None:
 | 
			
		||||
            user_settings = UserSettings.find_by_user(user)
 | 
			
		||||
            if user_settings is not None:
 | 
			
		||||
                user_settings_dict = user_settings.to_dict()
 | 
			
		||||
 | 
			
		||||
        return ChainMap(vars_dict, sub_overloads_dict, user_settings_dict)
 | 
			
		||||
 | 
			
		||||
    def get_user(self, user: User, section: str, option: Any, vars=None, fallback=object()) -> str:
 | 
			
		||||
        return super().get(section, option,
 | 
			
		||||
                           fallback=fallback,
 | 
			
		||||
                           vars=self.__get_combined_dict(vars, None, user))
 | 
			
		||||
 | 
			
		||||
    def getboolean_user(self, user: User, section: str, option: Any, vars=None, fallback=object()) -> bool:
 | 
			
		||||
        return super().getboolean(section, option,
 | 
			
		||||
                                  fallback=fallback,
 | 
			
		||||
                                  vars=self.__get_combined_dict(vars, None, user))
 | 
			
		||||
 | 
			
		||||
    def getint_user(self, user: User, section: str, option: Any, vars=None, fallback=object()) -> int:
 | 
			
		||||
        return super().getint(section, option,
 | 
			
		||||
                              fallback=fallback,
 | 
			
		||||
                              vars=self.__get_combined_dict(vars, None, user))
 | 
			
		||||
 | 
			
		||||
    def getfloat_user(self, user: User, section: str, option: Any, vars=None, fallback=object()) -> float:
 | 
			
		||||
        return super().getfloat(section, option,
 | 
			
		||||
                                fallback=fallback,
 | 
			
		||||
                                vars=self.__get_combined_dict(vars, None, user))
 | 
			
		||||
 | 
			
		||||
    def get_sub(self, sub: Subscription, section: str, option: Any, vars=None, fallback=object()) -> str:
 | 
			
		||||
        return super().get(section, option,
 | 
			
		||||
                           fallback=fallback,
 | 
			
		||||
                           vars=self.__get_combined_dict(vars, sub, sub.user))
 | 
			
		||||
 | 
			
		||||
    def getboolean_sub(self, sub: Subscription, section: str, option: Any, vars=None, fallback=object()) -> bool:
 | 
			
		||||
        return super().getboolean(section, option,
 | 
			
		||||
                                  fallback=fallback,
 | 
			
		||||
                                  vars=self.__get_combined_dict(vars, sub, sub.user))
 | 
			
		||||
 | 
			
		||||
    def getint_sub(self, sub: Subscription, section: str, option: Any, vars=None, fallback=object()) -> int:
 | 
			
		||||
        return super().getint(section, option,
 | 
			
		||||
                              fallback=fallback,
 | 
			
		||||
                              vars=self.__get_combined_dict(vars, sub, sub.user))
 | 
			
		||||
 | 
			
		||||
    def getfloat_sub(self, sub: Subscription, section: str, option: Any, vars=None, fallback=object()) -> float:
 | 
			
		||||
        return super().getfloat(section, option,
 | 
			
		||||
                                fallback=fallback,
 | 
			
		||||
                                vars=self.__get_combined_dict(vars, sub, sub.user))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
settings = AppSettings()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def initialize_app_config():
 | 
			
		||||
    settings.initialize()
 | 
			
		||||
    __initialize_logger()
 | 
			
		||||
    logging.info('Application started!')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __initialize_logger():
 | 
			
		||||
    log_level_str = settings.get('global', 'LogLevel', fallback='INFO')
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        log_level = getattr(logging, log_level_str)
 | 
			
		||||
        logging.basicConfig(filename=_LOG_PATH, level=log_level, format=_LOG_FORMAT)
 | 
			
		||||
 | 
			
		||||
    except AttributeError:
 | 
			
		||||
        logging.basicConfig(filename=_LOG_PATH, level=logging.INFO, format=_LOG_FORMAT)
 | 
			
		||||
        logging.warning('Invalid log level "%s" in config file.', log_level_str)
 | 
			
		||||
							
								
								
									
										11
									
								
								app/YtManagerApp/appmain.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/YtManagerApp/appmain.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
from .appconfig import initialize_app_config
 | 
			
		||||
from .scheduler import initialize_scheduler
 | 
			
		||||
from .management.jobs.synchronize import schedule_synchronize_global
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def main():
 | 
			
		||||
    initialize_app_config()
 | 
			
		||||
    initialize_scheduler()
 | 
			
		||||
    schedule_synchronize_global()
 | 
			
		||||
    logging.info('Initialization complete.')
 | 
			
		||||
							
								
								
									
										11
									
								
								app/YtManagerApp/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/YtManagerApp/apps.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class YtManagerAppConfig(AppConfig):
 | 
			
		||||
    name = 'YtManagerApp'
 | 
			
		||||
 | 
			
		||||
    def ready(self):
 | 
			
		||||
        # Run server using --noreload to avoid having the scheduler run on 2 different processes
 | 
			
		||||
        from .appmain import main
 | 
			
		||||
        main()
 | 
			
		||||
							
								
								
									
										0
									
								
								app/YtManagerApp/management/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								app/YtManagerApp/management/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										103
									
								
								app/YtManagerApp/management/downloader.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								app/YtManagerApp/management/downloader.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,103 @@
 | 
			
		||||
from YtManagerApp.appconfig import settings
 | 
			
		||||
from YtManagerApp.management.jobs.download_video import schedule_download_video
 | 
			
		||||
from YtManagerApp.models import Video, Subscription, VIDEO_ORDER_MAPPING
 | 
			
		||||
from django.conf import settings as srv_settings
 | 
			
		||||
import logging
 | 
			
		||||
import requests
 | 
			
		||||
import mimetypes
 | 
			
		||||
import os
 | 
			
		||||
from urllib.parse import urljoin
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger('downloader')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_subscription_config(sub: Subscription):
 | 
			
		||||
    enabled = settings.getboolean_sub(sub, 'user', 'AutoDownload')
 | 
			
		||||
 | 
			
		||||
    global_limit = -1
 | 
			
		||||
    if len(settings.get_sub(sub, 'user', 'DownloadGlobalLimit')) > 0:
 | 
			
		||||
        global_limit = settings.getint_sub(sub, 'user', 'DownloadGlobalLimit')
 | 
			
		||||
 | 
			
		||||
    limit = -1
 | 
			
		||||
    if len(settings.get_sub(sub, 'user', 'DownloadSubscriptionLimit')) > 0:
 | 
			
		||||
        limit = settings.getint_sub(sub, 'user', 'DownloadSubscriptionLimit')
 | 
			
		||||
 | 
			
		||||
    order = settings.get_sub(sub, 'user', 'DownloadOrder')
 | 
			
		||||
    order = VIDEO_ORDER_MAPPING[order]
 | 
			
		||||
 | 
			
		||||
    return enabled, global_limit, limit, order
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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)
 | 
			
		||||
    log.info('Determined settings enabled=%s global_limit=%d limit=%d order="%s"', enabled, global_limit, limit, order)
 | 
			
		||||
 | 
			
		||||
    if enabled:
 | 
			
		||||
        videos_to_download = Video.objects\
 | 
			
		||||
            .filter(subscription=sub, downloaded_path__isnull=True, watched=False)\
 | 
			
		||||
            .order_by(order)
 | 
			
		||||
 | 
			
		||||
        log.info('%d download candidates.', len(videos_to_download))
 | 
			
		||||
 | 
			
		||||
        if global_limit > 0:
 | 
			
		||||
            global_downloaded = Video.objects.filter(subscription__user=sub.user, downloaded_path__isnull=False).count()
 | 
			
		||||
            allowed_count = max(global_limit - global_downloaded, 0)
 | 
			
		||||
            videos_to_download = videos_to_download[0:allowed_count]
 | 
			
		||||
            log.info('Global limit is set, can only download up to %d videos.', allowed_count)
 | 
			
		||||
 | 
			
		||||
        if limit > 0:
 | 
			
		||||
            sub_downloaded = Video.objects.filter(subscription=sub, downloaded_path__isnull=False).count()
 | 
			
		||||
            allowed_count = max(limit - sub_downloaded, 0)
 | 
			
		||||
            videos_to_download = videos_to_download[0:allowed_count]
 | 
			
		||||
            log.info('Limit is set, can only download up to %d videos.', allowed_count)
 | 
			
		||||
 | 
			
		||||
        # enqueue download
 | 
			
		||||
        for video in videos_to_download:
 | 
			
		||||
            log.info('Enqueuing video %d [%s %s] index=%d', video.id, video.video_id, video.name, video.playlist_index)
 | 
			
		||||
            schedule_download_video(video)
 | 
			
		||||
 | 
			
		||||
    log.info('Finished processing subscription %d [%s %s]', sub.id, sub.playlist_id, sub.id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def downloader_process_all():
 | 
			
		||||
    for subscription in Subscription.objects.all():
 | 
			
		||||
        downloader_process_subscription(subscription)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def fetch_thumbnail(url, object_type, identifier, quality):
 | 
			
		||||
 | 
			
		||||
    log.info('Fetching thumbnail url=%s object_type=%s identifier=%s quality=%s', url, object_type, identifier, quality)
 | 
			
		||||
 | 
			
		||||
    # Make request to obtain mime type
 | 
			
		||||
    try:
 | 
			
		||||
        response = requests.get(url, stream=True)
 | 
			
		||||
    except requests.exceptions.RequestException as e:
 | 
			
		||||
        log.error('Failed to fetch thumbnail %s. Error: %s', url, e)
 | 
			
		||||
        return url
 | 
			
		||||
 | 
			
		||||
    ext = mimetypes.guess_extension(response.headers['Content-Type'])
 | 
			
		||||
 | 
			
		||||
    # Build file path
 | 
			
		||||
    file_name = f"{identifier}-{quality}{ext}"
 | 
			
		||||
    abs_path_dir = os.path.join(srv_settings.MEDIA_ROOT, "thumbs", object_type)
 | 
			
		||||
    abs_path = os.path.join(abs_path_dir, file_name)
 | 
			
		||||
 | 
			
		||||
    # Store image
 | 
			
		||||
    try:
 | 
			
		||||
        os.makedirs(abs_path_dir, exist_ok=True)
 | 
			
		||||
        with open(abs_path, "wb") as f:
 | 
			
		||||
            for chunk in response.iter_content(chunk_size=1024):
 | 
			
		||||
                if chunk:
 | 
			
		||||
                    f.write(chunk)
 | 
			
		||||
    except requests.exceptions.RequestException as e:
 | 
			
		||||
        log.error('Error while downloading stream for thumbnail %s. Error: %s', url, e)
 | 
			
		||||
        return url
 | 
			
		||||
    except OSError as e:
 | 
			
		||||
        log.error('Error while writing to file %s for thumbnail %s. Error: %s', abs_path, url, e)
 | 
			
		||||
        return url
 | 
			
		||||
 | 
			
		||||
    # Return
 | 
			
		||||
    media_url = urljoin(srv_settings.MEDIA_URL, f"thumbs/{object_type}/{file_name}")
 | 
			
		||||
    return media_url
 | 
			
		||||
							
								
								
									
										0
									
								
								app/YtManagerApp/management/jobs/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								app/YtManagerApp/management/jobs/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										39
									
								
								app/YtManagerApp/management/jobs/delete_video.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								app/YtManagerApp/management/jobs/delete_video.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,39 @@
 | 
			
		||||
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:
 | 
			
		||||
    """
 | 
			
		||||
    job = scheduler.scheduler.add_job(delete_video, args=[video])
 | 
			
		||||
    log.info('Scheduled delete video job video=(%s), job=%s', video, job.id)
 | 
			
		||||
							
								
								
									
										110
									
								
								app/YtManagerApp/management/jobs/download_video.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								app/YtManagerApp/management/jobs/download_video.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,110 @@
 | 
			
		||||
from YtManagerApp.models import Video
 | 
			
		||||
from YtManagerApp import scheduler
 | 
			
		||||
from YtManagerApp.appconfig import settings
 | 
			
		||||
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):
 | 
			
		||||
    # resolve path
 | 
			
		||||
    pattern_dict = {
 | 
			
		||||
        'channel': video.subscription.channel_name,
 | 
			
		||||
        'channel_id': video.subscription.channel_id,
 | 
			
		||||
        'playlist': video.subscription.name,
 | 
			
		||||
        'playlist_id': video.subscription.playlist_id,
 | 
			
		||||
        'playlist_index': "{:03d}".format(1 + video.playlist_index),
 | 
			
		||||
        'title': video.name,
 | 
			
		||||
        'id': video.video_id,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    download_path = settings.get_sub(video.subscription, 'user', 'DownloadPath')
 | 
			
		||||
    output_pattern = __get_valid_path(settings.get_sub(
 | 
			
		||||
        video.subscription, 'user', 'DownloadFilePattern', vars=pattern_dict))
 | 
			
		||||
 | 
			
		||||
    output_path = os.path.join(download_path, output_pattern)
 | 
			
		||||
    output_path = os.path.normpath(output_path)
 | 
			
		||||
 | 
			
		||||
    youtube_dl_params = {
 | 
			
		||||
        'logger': log_youtube_dl,
 | 
			
		||||
        'format': settings.get_sub(video.subscription, 'user', 'DownloadFormat'),
 | 
			
		||||
        'outtmpl': output_path,
 | 
			
		||||
        'writethumbnail': True,
 | 
			
		||||
        'writedescription': True,
 | 
			
		||||
        'writesubtitles': settings.getboolean_sub(video.subscription, 'user', 'DownloadSubtitles'),
 | 
			
		||||
        'writeautomaticsub': settings.getboolean_sub(video.subscription, 'user', 'DownloadAutogeneratedSubtitles'),
 | 
			
		||||
        'allsubtitles': settings.getboolean_sub(video.subscription, 'user', 'DownloadSubtitlesAll'),
 | 
			
		||||
        'postprocessors': [
 | 
			
		||||
            {
 | 
			
		||||
                'key': 'FFmpegMetadata'
 | 
			
		||||
            },
 | 
			
		||||
        ]
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    sub_langs = settings.get_sub(video.subscription, 'user', 'DownloadSubtitlesLangs').split(',')
 | 
			
		||||
    sub_langs = [i.strip() for i in sub_langs]
 | 
			
		||||
    if len(sub_langs) > 0:
 | 
			
		||||
        youtube_dl_params['subtitleslangs'] = sub_langs
 | 
			
		||||
 | 
			
		||||
    sub_format = settings.get_sub(video.subscription, 'user', 'DownloadSubtitlesFormat')
 | 
			
		||||
    if len(sub_format) > 0:
 | 
			
		||||
        youtube_dl_params['subtitlesformat'] = sub_format
 | 
			
		||||
 | 
			
		||||
    return youtube_dl_params, output_path
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def download_video(video: Video, attempt: int = 1):
 | 
			
		||||
 | 
			
		||||
    log.info('Downloading video %d [%s %s]', video.id, video.video_id, video.name)
 | 
			
		||||
 | 
			
		||||
    max_attempts = settings.getint_sub(video.subscription, 'user', 'DownloadMaxAttempts', fallback=3)
 | 
			
		||||
 | 
			
		||||
    youtube_dl_params, output_path = __build_youtube_dl_params(video)
 | 
			
		||||
    with youtube_dl.YoutubeDL(youtube_dl_params) as yt:
 | 
			
		||||
        ret = yt.download(["https://www.youtube.com/watch?v=" + video.video_id])
 | 
			
		||||
 | 
			
		||||
    log.info('Download finished with code %d', ret)
 | 
			
		||||
 | 
			
		||||
    if ret == 0:
 | 
			
		||||
        video.downloaded_path = output_path
 | 
			
		||||
        video.save()
 | 
			
		||||
        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)
 | 
			
		||||
        __schedule_download_video(video, attempt + 1)
 | 
			
		||||
 | 
			
		||||
    else:
 | 
			
		||||
        log.error('Multiple attempts to download video %d [%s %s] failed!', video.id, video.video_id, video.name)
 | 
			
		||||
        video.downloaded_path = ''
 | 
			
		||||
        video.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __schedule_download_video(video: Video, attempt=1):
 | 
			
		||||
    job = scheduler.scheduler.add_job(download_video, args=[video, attempt])
 | 
			
		||||
    log.info('Scheduled download video job video=(%s), attempt=%d, job=%s', video, attempt, job.id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def schedule_download_video(video: Video):
 | 
			
		||||
    """
 | 
			
		||||
    Schedules a download video job to run immediately.
 | 
			
		||||
    :param video:
 | 
			
		||||
    :return:
 | 
			
		||||
    """
 | 
			
		||||
    __schedule_download_video(video)
 | 
			
		||||
							
								
								
									
										162
									
								
								app/YtManagerApp/management/jobs/synchronize.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								app/YtManagerApp/management/jobs/synchronize.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,162 @@
 | 
			
		||||
import errno
 | 
			
		||||
import mimetypes
 | 
			
		||||
from threading import Lock
 | 
			
		||||
 | 
			
		||||
from apscheduler.triggers.cron import CronTrigger
 | 
			
		||||
 | 
			
		||||
from YtManagerApp import scheduler
 | 
			
		||||
from YtManagerApp.appconfig import settings
 | 
			
		||||
from YtManagerApp.management.downloader import fetch_thumbnail, downloader_process_all, downloader_process_subscription
 | 
			
		||||
from YtManagerApp.models import *
 | 
			
		||||
from YtManagerApp.utils import youtube
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger('sync')
 | 
			
		||||
__lock = Lock()
 | 
			
		||||
 | 
			
		||||
_ENABLE_UPDATE_STATS = True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __check_new_videos_sub(subscription: Subscription, yt_api: youtube.YoutubeAPI):
 | 
			
		||||
    # Get list of videos
 | 
			
		||||
    for item in yt_api.playlist_items(subscription.playlist_id):
 | 
			
		||||
        results = Video.objects.filter(video_id=item.resource_video_id, subscription=subscription)
 | 
			
		||||
        if len(results) == 0:
 | 
			
		||||
            log.info('New video for subscription %s: %s %s"', subscription, item.resource_video_id, item.title)
 | 
			
		||||
            Video.create(item, subscription)
 | 
			
		||||
 | 
			
		||||
    if _ENABLE_UPDATE_STATS:
 | 
			
		||||
        all_vids = Video.objects.filter(subscription=subscription)
 | 
			
		||||
        all_vids_ids = [video.video_id for video in all_vids]
 | 
			
		||||
        all_vids_dict = {v.video_id: v for v in all_vids}
 | 
			
		||||
 | 
			
		||||
        for yt_video in yt_api.videos(all_vids_ids, part='id,statistics'):
 | 
			
		||||
            video = all_vids_dict.get(yt_video.id)
 | 
			
		||||
 | 
			
		||||
            if yt_video.n_likes is not None \
 | 
			
		||||
                    and yt_video.n_dislikes is not None \
 | 
			
		||||
                    and yt_video.n_likes + yt_video.n_dislikes > 0:
 | 
			
		||||
                video.rating = yt_video.n_likes / (yt_video.n_likes + yt_video.n_dislikes)
 | 
			
		||||
 | 
			
		||||
            video.views = yt_video.n_views
 | 
			
		||||
            video.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __detect_deleted(subscription: Subscription):
 | 
			
		||||
 | 
			
		||||
    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 settings.getboolean_sub(subscription, 'user', 'MarkDeletedAsWatched'):
 | 
			
		||||
                video.watched = True
 | 
			
		||||
 | 
			
		||||
            video.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __fetch_thumbnails_obj(iterable, obj_type, id_attr):
 | 
			
		||||
    for obj in iterable:
 | 
			
		||||
        if obj.icon_default.startswith("http"):
 | 
			
		||||
            obj.icon_default = fetch_thumbnail(obj.icon_default, obj_type, getattr(obj, id_attr), 'default')
 | 
			
		||||
        if obj.icon_best.startswith("http"):
 | 
			
		||||
            obj.icon_best = fetch_thumbnail(obj.icon_best, obj_type, getattr(obj, id_attr), 'best')
 | 
			
		||||
        obj.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __fetch_thumbnails():
 | 
			
		||||
    log.info("Fetching subscription thumbnails... ")
 | 
			
		||||
    __fetch_thumbnails_obj(Subscription.objects.filter(icon_default__istartswith='http'), 'sub', 'playlist_id')
 | 
			
		||||
    __fetch_thumbnails_obj(Subscription.objects.filter(icon_best__istartswith='http'), 'sub', 'playlist_id')
 | 
			
		||||
 | 
			
		||||
    log.info("Fetching video thumbnails... ")
 | 
			
		||||
    __fetch_thumbnails_obj(Video.objects.filter(icon_default__istartswith='http'), 'video', 'video_id')
 | 
			
		||||
    __fetch_thumbnails_obj(Video.objects.filter(icon_best__istartswith='http'), 'video', 'video_id')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def synchronize():
 | 
			
		||||
    if not __lock.acquire(blocking=False):
 | 
			
		||||
        # Synchronize already running in another thread
 | 
			
		||||
        log.info("Synchronize already running in another thread")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        log.info("Running scheduled synchronization... ")
 | 
			
		||||
 | 
			
		||||
        # Sync subscribed playlists/channels
 | 
			
		||||
        log.info("Sync - checking videos")
 | 
			
		||||
        yt_api = youtube.YoutubeAPI.build_public()
 | 
			
		||||
        for subscription in Subscription.objects.all():
 | 
			
		||||
            __check_new_videos_sub(subscription, yt_api)
 | 
			
		||||
            __detect_deleted(subscription)
 | 
			
		||||
 | 
			
		||||
        log.info("Sync - checking for videos to download")
 | 
			
		||||
        downloader_process_all()
 | 
			
		||||
 | 
			
		||||
        log.info("Sync - fetching missing thumbnails")
 | 
			
		||||
        __fetch_thumbnails()
 | 
			
		||||
 | 
			
		||||
        log.info("Synchronization finished.")
 | 
			
		||||
 | 
			
		||||
    finally:
 | 
			
		||||
        __lock.release()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def synchronize_subscription(subscription: Subscription):
 | 
			
		||||
    __lock.acquire()
 | 
			
		||||
    try:
 | 
			
		||||
        log.info("Running synchronization for single subscription %d [%s]", subscription.id, subscription.name)
 | 
			
		||||
        yt_api = youtube.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'))
 | 
			
		||||
    job = scheduler.scheduler.add_job(synchronize, trigger, max_instances=1, coalesce=True)
 | 
			
		||||
    log.info('Scheduled synchronize job job=%s', job.id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def schedule_synchronize_now():
 | 
			
		||||
    job = scheduler.scheduler.add_job(synchronize, max_instances=1, coalesce=True)
 | 
			
		||||
    log.info('Scheduled synchronize now job job=%s', job.id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def schedule_synchronize_now_subscription(subscription: Subscription):
 | 
			
		||||
    job = scheduler.scheduler.add_job(synchronize_subscription, args=[subscription])
 | 
			
		||||
    log.info('Scheduled synchronize subscription job subscription=(%s), job=%s', subscription, job.id)
 | 
			
		||||
							
								
								
									
										0
									
								
								app/YtManagerApp/management/subscriptions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								app/YtManagerApp/management/subscriptions.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										57
									
								
								app/YtManagerApp/management/videos.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								app/YtManagerApp/management/videos.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,57 @@
 | 
			
		||||
import re
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
 | 
			
		||||
from YtManagerApp.models import Subscription, Video, SubscriptionFolder
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_videos(user: User,
 | 
			
		||||
               sort_order: Optional[str],
 | 
			
		||||
               query: Optional[str] = None,
 | 
			
		||||
               subscription_id: Optional[int] = None,
 | 
			
		||||
               folder_id: Optional[int] = None,
 | 
			
		||||
               only_watched: Optional[bool] = None,
 | 
			
		||||
               only_downloaded: Optional[bool] = None,
 | 
			
		||||
               ):
 | 
			
		||||
 | 
			
		||||
    filter_args = []
 | 
			
		||||
    filter_kwargs = {
 | 
			
		||||
        'subscription__user': user
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    # Process query string - basically, we break it down into words,
 | 
			
		||||
    # and then search for the given text in the name, description, uploader name and subscription name
 | 
			
		||||
    if query is not None:
 | 
			
		||||
        for match in re.finditer(r'\w+', query):
 | 
			
		||||
            word = match[0]
 | 
			
		||||
            filter_args.append(Q(name__icontains=word)
 | 
			
		||||
                               | Q(description__icontains=word)
 | 
			
		||||
                               | Q(uploader_name__icontains=word)
 | 
			
		||||
                               | Q(subscription__name__icontains=word))
 | 
			
		||||
 | 
			
		||||
    # Subscription id
 | 
			
		||||
    if subscription_id is not None:
 | 
			
		||||
        filter_kwargs['subscription_id'] = subscription_id
 | 
			
		||||
 | 
			
		||||
    # Folder id
 | 
			
		||||
    if folder_id is not None:
 | 
			
		||||
        # Visit function - returns only the subscription IDs
 | 
			
		||||
        def visit(node):
 | 
			
		||||
            if isinstance(node, Subscription):
 | 
			
		||||
                return node.id
 | 
			
		||||
            return None
 | 
			
		||||
        filter_kwargs['subscription_id__in'] = SubscriptionFolder.traverse(folder_id, user, visit)
 | 
			
		||||
 | 
			
		||||
    # Only watched
 | 
			
		||||
    if only_watched is not None:
 | 
			
		||||
        filter_kwargs['watched'] = only_watched
 | 
			
		||||
 | 
			
		||||
    # Only downloaded
 | 
			
		||||
    # - not downloaded (False) -> is null (True)
 | 
			
		||||
    # - downloaded (True) -> is not null (False)
 | 
			
		||||
    if only_downloaded is not None:
 | 
			
		||||
        filter_kwargs['downloaded_path__isnull'] = not only_downloaded
 | 
			
		||||
 | 
			
		||||
    return Video.objects.filter(*filter_args, **filter_kwargs).order_by(sort_order)
 | 
			
		||||
							
								
								
									
										102
									
								
								app/YtManagerApp/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								app/YtManagerApp/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,102 @@
 | 
			
		||||
# Generated by Django 2.1.2 on 2018-10-11 00:19
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    initial = True
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='Channel',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('channel_id', models.TextField(unique=True)),
 | 
			
		||||
                ('username', models.TextField(null=True, unique=True)),
 | 
			
		||||
                ('custom_url', models.TextField(null=True, unique=True)),
 | 
			
		||||
                ('name', models.TextField()),
 | 
			
		||||
                ('description', models.TextField()),
 | 
			
		||||
                ('icon_default', models.TextField()),
 | 
			
		||||
                ('icon_best', models.TextField()),
 | 
			
		||||
                ('upload_playlist_id', models.TextField()),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='Subscription',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('name', models.TextField()),
 | 
			
		||||
                ('playlist_id', models.TextField(unique=True)),
 | 
			
		||||
                ('description', models.TextField()),
 | 
			
		||||
                ('icon_default', models.TextField()),
 | 
			
		||||
                ('icon_best', models.TextField()),
 | 
			
		||||
                ('auto_download', models.BooleanField(null=True)),
 | 
			
		||||
                ('download_limit', models.IntegerField(null=True)),
 | 
			
		||||
                ('download_order', models.TextField(null=True)),
 | 
			
		||||
                ('manager_delete_after_watched', models.BooleanField(null=True)),
 | 
			
		||||
                ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='YtManagerApp.Channel')),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='SubscriptionFolder',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('name', models.TextField()),
 | 
			
		||||
                ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='YtManagerApp.SubscriptionFolder')),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='UserSettings',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('mark_deleted_as_watched', models.BooleanField(null=True)),
 | 
			
		||||
                ('delete_watched', models.BooleanField(null=True)),
 | 
			
		||||
                ('auto_download', models.BooleanField(null=True)),
 | 
			
		||||
                ('download_global_limit', models.IntegerField(null=True)),
 | 
			
		||||
                ('download_subscription_limit', models.IntegerField(null=True)),
 | 
			
		||||
                ('download_order', models.TextField(null=True)),
 | 
			
		||||
                ('download_path', models.TextField(null=True)),
 | 
			
		||||
                ('download_file_pattern', models.TextField(null=True)),
 | 
			
		||||
                ('download_format', models.TextField(null=True)),
 | 
			
		||||
                ('download_subtitles', models.BooleanField(null=True)),
 | 
			
		||||
                ('download_autogenerated_subtitles', models.BooleanField(null=True)),
 | 
			
		||||
                ('download_subtitles_all', models.BooleanField(null=True)),
 | 
			
		||||
                ('download_subtitles_langs', models.TextField(null=True)),
 | 
			
		||||
                ('download_subtitles_format', models.TextField(null=True)),
 | 
			
		||||
                ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='Video',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('video_id', models.TextField()),
 | 
			
		||||
                ('name', models.TextField()),
 | 
			
		||||
                ('description', models.TextField()),
 | 
			
		||||
                ('watched', models.BooleanField(default=False)),
 | 
			
		||||
                ('downloaded_path', models.TextField(blank=True, null=True)),
 | 
			
		||||
                ('playlist_index', models.IntegerField()),
 | 
			
		||||
                ('publish_date', models.DateTimeField()),
 | 
			
		||||
                ('icon_default', models.TextField()),
 | 
			
		||||
                ('icon_best', models.TextField()),
 | 
			
		||||
                ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='YtManagerApp.Subscription')),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='parent_folder',
 | 
			
		||||
            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='YtManagerApp.SubscriptionFolder'),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='user',
 | 
			
		||||
            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										22
									
								
								app/YtManagerApp/migrations/0002_subscriptionfolder_user.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								app/YtManagerApp/migrations/0002_subscriptionfolder_user.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
# Generated by Django 2.1.2 on 2018-10-11 18:16
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
			
		||||
        ('YtManagerApp', '0001_initial'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='subscriptionfolder',
 | 
			
		||||
            name='user',
 | 
			
		||||
            field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
 | 
			
		||||
            preserve_default=False,
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										29
									
								
								app/YtManagerApp/migrations/0003_auto_20181013_2018.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								app/YtManagerApp/migrations/0003_auto_20181013_2018.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,29 @@
 | 
			
		||||
# Generated by Django 2.1.2 on 2018-10-13 17:18
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('YtManagerApp', '0002_subscriptionfolder_user'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='video',
 | 
			
		||||
            name='rating',
 | 
			
		||||
            field=models.FloatField(default=0.5),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='video',
 | 
			
		||||
            name='uploader_name',
 | 
			
		||||
            field=models.TextField(default=None),
 | 
			
		||||
            preserve_default=False,
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='video',
 | 
			
		||||
            name='views',
 | 
			
		||||
            field=models.IntegerField(default=0),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										18
									
								
								app/YtManagerApp/migrations/0004_auto_20181014_1702.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								app/YtManagerApp/migrations/0004_auto_20181014_1702.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 2.1.2 on 2018-10-14 14:02
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('YtManagerApp', '0003_auto_20181013_2018'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='subscriptionfolder',
 | 
			
		||||
            name='name',
 | 
			
		||||
            field=models.CharField(max_length=250),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										134
									
								
								app/YtManagerApp/migrations/0005_auto_20181026_2013.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								app/YtManagerApp/migrations/0005_auto_20181026_2013.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,134 @@
 | 
			
		||||
# Generated by Django 2.1.2 on 2018-10-26 17:13
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
import django.db.models.functions.text
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('YtManagerApp', '0004_auto_20181014_1702'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterModelOptions(
 | 
			
		||||
            name='subscriptionfolder',
 | 
			
		||||
            options={'ordering': [django.db.models.functions.text.Lower('parent__name'), django.db.models.functions.text.Lower('name')]},
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='auto_download',
 | 
			
		||||
            field=models.BooleanField(blank=True, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='download_limit',
 | 
			
		||||
            field=models.IntegerField(blank=True, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='download_order',
 | 
			
		||||
            field=models.CharField(blank=True, choices=[('newest', 'Newest'), ('oldest', 'Oldest'), ('playlist', 'Playlist order'), ('playlist_reverse', 'Reverse playlist order'), ('popularity', 'Popularity'), ('rating', 'Top rated')], max_length=128, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='icon_best',
 | 
			
		||||
            field=models.CharField(max_length=1024),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='icon_default',
 | 
			
		||||
            field=models.CharField(max_length=1024),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='manager_delete_after_watched',
 | 
			
		||||
            field=models.BooleanField(blank=True, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='name',
 | 
			
		||||
            field=models.CharField(max_length=1024),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='parent_folder',
 | 
			
		||||
            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='YtManagerApp.SubscriptionFolder'),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='playlist_id',
 | 
			
		||||
            field=models.CharField(max_length=128),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='usersettings',
 | 
			
		||||
            name='auto_download',
 | 
			
		||||
            field=models.BooleanField(blank=True, help_text='Enables or disables automatic downloading.', null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='usersettings',
 | 
			
		||||
            name='delete_watched',
 | 
			
		||||
            field=models.BooleanField(blank=True, help_text='Videos marked as watched are automatically deleted.', null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='usersettings',
 | 
			
		||||
            name='download_autogenerated_subtitles',
 | 
			
		||||
            field=models.BooleanField(blank=True, help_text='Enables downloading the automatically generated subtitle. The flag is passed directly to youtube-dl. You can find more information <a href="https://github.com/rg3/youtube-dl/blob/master/README.md#subtitle-options">here</a>.', null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='usersettings',
 | 
			
		||||
            name='download_file_pattern',
 | 
			
		||||
            field=models.CharField(blank=True, help_text='A pattern which describes how downloaded files are organized. Extensions are automatically appended. You can use the following fields, using the <code>${field}</code> syntax: channel, channel_id, playlist, playlist_id, playlist_index, title, id. Example: <code>${channel}/${playlist}/S01E${playlist_index} - ${title} [${id}]</code>', max_length=1024, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='usersettings',
 | 
			
		||||
            name='download_format',
 | 
			
		||||
            field=models.CharField(blank=True, help_text='Download format that will be passed to youtube-dl.  See the <a href="https://github.com/rg3/youtube-dl/blob/master/README.md#format-selection"> youtube-dl documentation</a> for more details.', max_length=256, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='usersettings',
 | 
			
		||||
            name='download_global_limit',
 | 
			
		||||
            field=models.IntegerField(blank=True, help_text='Limits the total number of videos downloaded (-1 = no limit).', null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='usersettings',
 | 
			
		||||
            name='download_order',
 | 
			
		||||
            field=models.CharField(blank=True, choices=[('newest', 'Newest'), ('oldest', 'Oldest'), ('playlist', 'Playlist order'), ('playlist_reverse', 'Reverse playlist order'), ('popularity', 'Popularity'), ('rating', 'Top rated')], help_text='The order in which videos will be downloaded.', max_length=100, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='usersettings',
 | 
			
		||||
            name='download_path',
 | 
			
		||||
            field=models.CharField(blank=True, help_text='Path on the disk where downloaded videos are stored.  You can use environment variables using syntax: <code>${env:...}</code>', max_length=1024, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='usersettings',
 | 
			
		||||
            name='download_subscription_limit',
 | 
			
		||||
            field=models.IntegerField(blank=True, help_text='Limits the number of videos downloaded per subscription (-1 = no limit).  This setting can be overriden for each individual subscription in the subscription edit dialog.', null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='usersettings',
 | 
			
		||||
            name='download_subtitles',
 | 
			
		||||
            field=models.BooleanField(blank=True, help_text='Enable downloading subtitles for the videos. The flag is passed directly to youtube-dl. You can find more information <a href="https://github.com/rg3/youtube-dl/blob/master/README.md#subtitle-options">here</a>.', null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='usersettings',
 | 
			
		||||
            name='download_subtitles_all',
 | 
			
		||||
            field=models.BooleanField(blank=True, help_text='If enabled, all the subtitles in all the available languages will be downloaded. The flag is passed directly to youtube-dl. You can find more information <a href="https://github.com/rg3/youtube-dl/blob/master/README.md#subtitle-options">here</a>.', null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='usersettings',
 | 
			
		||||
            name='download_subtitles_format',
 | 
			
		||||
            field=models.CharField(blank=True, help_text='Subtitles format preference. Examples: srt/ass/best The flag is passed directly to youtube-dl. You can find more information <a href="https://github.com/rg3/youtube-dl/blob/master/README.md#subtitle-options">here</a>.', max_length=100, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='usersettings',
 | 
			
		||||
            name='download_subtitles_langs',
 | 
			
		||||
            field=models.CharField(blank=True, help_text='Comma separated list of languages for which subtitles will be downloaded. The flag is passed directly to youtube-dl. You can find more information <a href="https://github.com/rg3/youtube-dl/blob/master/README.md#subtitle-options">here</a>.', max_length=250, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='usersettings',
 | 
			
		||||
            name='mark_deleted_as_watched',
 | 
			
		||||
            field=models.BooleanField(blank=True, help_text="When a downloaded video is deleted from the system, it will be marked as 'watched'.", null=True),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										18
									
								
								app/YtManagerApp/migrations/0006_auto_20181027_0256.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								app/YtManagerApp/migrations/0006_auto_20181027_0256.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
# Generated by Django 2.1.2 on 2018-10-26 23:56
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('YtManagerApp', '0005_auto_20181026_2013'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RenameField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            old_name='manager_delete_after_watched',
 | 
			
		||||
            new_name='delete_after_watched',
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										32
									
								
								app/YtManagerApp/migrations/0007_auto_20181029_1638.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								app/YtManagerApp/migrations/0007_auto_20181029_1638.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
			
		||||
# Generated by Django 2.1.2 on 2018-10-29 16:38
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('YtManagerApp', '0006_auto_20181027_0256'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RemoveField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='channel',
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='channel_id',
 | 
			
		||||
            field=models.CharField(default='test', max_length=128),
 | 
			
		||||
            preserve_default=False,
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='channel_name',
 | 
			
		||||
            field=models.CharField(default='Unknown', max_length=1024),
 | 
			
		||||
            preserve_default=False,
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.DeleteModel(
 | 
			
		||||
            name='Channel',
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										0
									
								
								app/YtManagerApp/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								app/YtManagerApp/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										385
									
								
								app/YtManagerApp/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										385
									
								
								app/YtManagerApp/models.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,385 @@
 | 
			
		||||
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
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.db.models.functions import Lower
 | 
			
		||||
from YtManagerApp.utils import youtube
 | 
			
		||||
 | 
			
		||||
# help_text = user shown text
 | 
			
		||||
# verbose_name = user shown name
 | 
			
		||||
# null = nullable, blank = user is allowed to set value to empty
 | 
			
		||||
VIDEO_ORDER_CHOICES = [
 | 
			
		||||
    ('newest', 'Newest'),
 | 
			
		||||
    ('oldest', 'Oldest'),
 | 
			
		||||
    ('playlist', 'Playlist order'),
 | 
			
		||||
    ('playlist_reverse', 'Reverse playlist order'),
 | 
			
		||||
    ('popularity', 'Popularity'),
 | 
			
		||||
    ('rating', 'Top rated'),
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
VIDEO_ORDER_MAPPING = {
 | 
			
		||||
    'newest': '-publish_date',
 | 
			
		||||
    'oldest': 'publish_date',
 | 
			
		||||
    'playlist': 'playlist_index',
 | 
			
		||||
    'playlist_reverse': '-playlist_index',
 | 
			
		||||
    'popularity': '-views',
 | 
			
		||||
    'rating': '-rating'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserSettings(models.Model):
 | 
			
		||||
    user = models.OneToOneField(User, on_delete=models.CASCADE)
 | 
			
		||||
 | 
			
		||||
    mark_deleted_as_watched = models.BooleanField(
 | 
			
		||||
        null=True, blank=True,
 | 
			
		||||
        help_text='When a downloaded video is deleted from the system, it will be marked as \'watched\'.')
 | 
			
		||||
 | 
			
		||||
    delete_watched = models.BooleanField(
 | 
			
		||||
        null=True, blank=True,
 | 
			
		||||
        help_text='Videos marked as watched are automatically deleted.')
 | 
			
		||||
 | 
			
		||||
    auto_download = models.BooleanField(
 | 
			
		||||
        null=True, blank=True,
 | 
			
		||||
        help_text='Enables or disables automatic downloading.')
 | 
			
		||||
 | 
			
		||||
    download_global_limit = models.IntegerField(
 | 
			
		||||
        null=True, blank=True,
 | 
			
		||||
        help_text='Limits the total number of videos downloaded (-1 = no limit).')
 | 
			
		||||
 | 
			
		||||
    download_subscription_limit = models.IntegerField(
 | 
			
		||||
        null=True, blank=True,
 | 
			
		||||
        help_text='Limits the number of videos downloaded per subscription (-1 = no limit). '
 | 
			
		||||
                  ' This setting can be overriden for each individual subscription in the subscription edit dialog.')
 | 
			
		||||
 | 
			
		||||
    download_order = models.CharField(
 | 
			
		||||
        null=True, blank=True,
 | 
			
		||||
        max_length=100,
 | 
			
		||||
        choices=VIDEO_ORDER_CHOICES,
 | 
			
		||||
        help_text='The order in which videos will be downloaded.'
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    download_path = models.CharField(
 | 
			
		||||
        null=True, blank=True,
 | 
			
		||||
        max_length=1024,
 | 
			
		||||
        help_text='Path on the disk where downloaded videos are stored. '
 | 
			
		||||
                  ' You can use environment variables using syntax: <code>${env:...}</code>'
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    download_file_pattern = models.CharField(
 | 
			
		||||
        null=True, blank=True,
 | 
			
		||||
        max_length=1024,
 | 
			
		||||
        help_text='A pattern which describes how downloaded files are organized. Extensions are automatically appended.'
 | 
			
		||||
                  ' You can use the following fields, using the <code>${field}</code> syntax:'
 | 
			
		||||
                  ' channel, channel_id, playlist, playlist_id, playlist_index, title, id.'
 | 
			
		||||
                  ' Example: <code>${channel}/${playlist}/S01E${playlist_index} - ${title} [${id}]</code>')
 | 
			
		||||
 | 
			
		||||
    download_format = models.CharField(
 | 
			
		||||
        null=True, blank=True,
 | 
			
		||||
        max_length=256,
 | 
			
		||||
        help_text='Download format that will be passed to youtube-dl. '
 | 
			
		||||
                  ' See the <a href="https://github.com/rg3/youtube-dl/blob/master/README.md#format-selection">'
 | 
			
		||||
                  ' youtube-dl documentation</a> for more details.')
 | 
			
		||||
 | 
			
		||||
    download_subtitles = models.BooleanField(
 | 
			
		||||
        null=True, blank=True,
 | 
			
		||||
        help_text='Enable downloading subtitles for the videos.'
 | 
			
		||||
                  ' The flag is passed directly to youtube-dl. You can find more information'
 | 
			
		||||
                  ' <a href="https://github.com/rg3/youtube-dl/blob/master/README.md#subtitle-options">here</a>.')
 | 
			
		||||
 | 
			
		||||
    download_autogenerated_subtitles = models.BooleanField(
 | 
			
		||||
        null=True, blank=True,
 | 
			
		||||
        help_text='Enables downloading the automatically generated subtitle.'
 | 
			
		||||
                  ' The flag is passed directly to youtube-dl. You can find more information'
 | 
			
		||||
                  ' <a href="https://github.com/rg3/youtube-dl/blob/master/README.md#subtitle-options">here</a>.')
 | 
			
		||||
 | 
			
		||||
    download_subtitles_all = models.BooleanField(
 | 
			
		||||
        null=True, blank=True,
 | 
			
		||||
        help_text='If enabled, all the subtitles in all the available languages will be downloaded.'
 | 
			
		||||
                  ' The flag is passed directly to youtube-dl. You can find more information'
 | 
			
		||||
                  ' <a href="https://github.com/rg3/youtube-dl/blob/master/README.md#subtitle-options">here</a>.')
 | 
			
		||||
 | 
			
		||||
    download_subtitles_langs = models.CharField(
 | 
			
		||||
        null=True, blank=True,
 | 
			
		||||
        max_length=250,
 | 
			
		||||
        help_text='Comma separated list of languages for which subtitles will be downloaded.'
 | 
			
		||||
                  ' The flag is passed directly to youtube-dl. You can find more information'
 | 
			
		||||
                  ' <a href="https://github.com/rg3/youtube-dl/blob/master/README.md#subtitle-options">here</a>.')
 | 
			
		||||
 | 
			
		||||
    download_subtitles_format = models.CharField(
 | 
			
		||||
        null=True, blank=True,
 | 
			
		||||
        max_length=100,
 | 
			
		||||
        help_text='Subtitles format preference. Examples: srt/ass/best'
 | 
			
		||||
                  ' The flag is passed directly to youtube-dl. You can find more information'
 | 
			
		||||
                  ' <a href="https://github.com/rg3/youtube-dl/blob/master/README.md#subtitle-options">here</a>.')
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def find_by_user(user: User):
 | 
			
		||||
        result = UserSettings.objects.filter(user=user)
 | 
			
		||||
        if len(result) > 0:
 | 
			
		||||
            return result.first()
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return str(self.user)
 | 
			
		||||
 | 
			
		||||
    def to_dict(self):
 | 
			
		||||
        ret = {}
 | 
			
		||||
 | 
			
		||||
        if self.mark_deleted_as_watched is not None:
 | 
			
		||||
            ret['MarkDeletedAsWatched'] = self.mark_deleted_as_watched
 | 
			
		||||
        if self.delete_watched is not None:
 | 
			
		||||
            ret['DeleteWatched'] = self.delete_watched
 | 
			
		||||
        if self.auto_download is not None:
 | 
			
		||||
            ret['AutoDownload'] = self.auto_download
 | 
			
		||||
        if self.download_global_limit is not None:
 | 
			
		||||
            ret['DownloadGlobalLimit'] = self.download_global_limit
 | 
			
		||||
        if self.download_subscription_limit is not None:
 | 
			
		||||
            ret['DownloadSubscriptionLimit'] = self.download_subscription_limit
 | 
			
		||||
        if self.download_order is not None:
 | 
			
		||||
            ret['DownloadOrder'] = self.download_order
 | 
			
		||||
        if self.download_path is not None:
 | 
			
		||||
            ret['DownloadPath'] = self.download_path
 | 
			
		||||
        if self.download_file_pattern is not None:
 | 
			
		||||
            ret['DownloadFilePattern'] = self.download_file_pattern
 | 
			
		||||
        if self.download_format is not None:
 | 
			
		||||
            ret['DownloadFormat'] = self.download_format
 | 
			
		||||
        if self.download_subtitles is not None:
 | 
			
		||||
            ret['DownloadSubtitles'] = self.download_subtitles
 | 
			
		||||
        if self.download_autogenerated_subtitles is not None:
 | 
			
		||||
            ret['DownloadAutogeneratedSubtitles'] = self.download_autogenerated_subtitles
 | 
			
		||||
        if self.download_subtitles_all is not None:
 | 
			
		||||
            ret['DownloadSubtitlesAll'] = self.download_subtitles_all
 | 
			
		||||
        if self.download_subtitles_langs is not None:
 | 
			
		||||
            ret['DownloadSubtitlesLangs'] = self.download_subtitles_langs
 | 
			
		||||
        if self.download_subtitles_format is not None:
 | 
			
		||||
            ret['DownloadSubtitlesFormat'] = self.download_subtitles_format
 | 
			
		||||
 | 
			
		||||
        return ret
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SubscriptionFolder(models.Model):
 | 
			
		||||
    name = models.CharField(null=False, max_length=250)
 | 
			
		||||
    parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True)
 | 
			
		||||
    user = models.ForeignKey(User, on_delete=models.CASCADE, null=False, blank=False)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        ordering = [Lower('parent__name'), Lower('name')]
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        s = ""
 | 
			
		||||
        current = self
 | 
			
		||||
        while current is not None:
 | 
			
		||||
            s = current.name + " > " + s
 | 
			
		||||
            current = current.parent
 | 
			
		||||
        return s[:-3]
 | 
			
		||||
 | 
			
		||||
    def __repr__(self):
 | 
			
		||||
        return f'folder {self.id}, name="{self.name}"'
 | 
			
		||||
 | 
			
		||||
    def delete_folder(self, keep_subscriptions: bool):
 | 
			
		||||
        if keep_subscriptions:
 | 
			
		||||
 | 
			
		||||
            def visit(node: Union["SubscriptionFolder", "Subscription"]):
 | 
			
		||||
                if isinstance(node, Subscription):
 | 
			
		||||
                    node.parent_folder = None
 | 
			
		||||
                    node.save()
 | 
			
		||||
 | 
			
		||||
            SubscriptionFolder.traverse(self.id, self.user, visit)
 | 
			
		||||
 | 
			
		||||
        self.delete()
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def traverse(root_folder_id: Optional[int],
 | 
			
		||||
                 user: User,
 | 
			
		||||
                 visit_func: Callable[[Union["SubscriptionFolder", "Subscription"]], Any]):
 | 
			
		||||
 | 
			
		||||
        data_collected = []
 | 
			
		||||
 | 
			
		||||
        def collect(data):
 | 
			
		||||
            if data is not None:
 | 
			
		||||
                data_collected.append(data)
 | 
			
		||||
 | 
			
		||||
        # Visit root
 | 
			
		||||
        if root_folder_id is not None:
 | 
			
		||||
            root_folder = SubscriptionFolder.objects.get(id=root_folder_id)
 | 
			
		||||
            collect(visit_func(root_folder))
 | 
			
		||||
 | 
			
		||||
        queue = [root_folder_id]
 | 
			
		||||
        visited = []
 | 
			
		||||
 | 
			
		||||
        while len(queue) > 0:
 | 
			
		||||
            folder_id = queue.pop()
 | 
			
		||||
 | 
			
		||||
            if folder_id in visited:
 | 
			
		||||
                logging.error('Found folder tree cycle for folder id %d.', folder_id)
 | 
			
		||||
                continue
 | 
			
		||||
            visited.append(folder_id)
 | 
			
		||||
 | 
			
		||||
            for folder in SubscriptionFolder.objects.filter(parent_id=folder_id, user=user).order_by(Lower('name')):
 | 
			
		||||
                collect(visit_func(folder))
 | 
			
		||||
                queue.append(folder.id)
 | 
			
		||||
 | 
			
		||||
            for subscription in Subscription.objects.filter(parent_folder_id=folder_id, user=user).order_by(Lower('name')):
 | 
			
		||||
                collect(visit_func(subscription))
 | 
			
		||||
 | 
			
		||||
        return data_collected
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Subscription(models.Model):
 | 
			
		||||
    name = models.CharField(null=False, max_length=1024)
 | 
			
		||||
    parent_folder = models.ForeignKey(SubscriptionFolder, on_delete=models.CASCADE, null=True, blank=True)
 | 
			
		||||
    playlist_id = models.CharField(null=False, max_length=128)
 | 
			
		||||
    description = models.TextField()
 | 
			
		||||
    channel_id = models.CharField(max_length=128)
 | 
			
		||||
    channel_name = models.CharField(max_length=1024)
 | 
			
		||||
    icon_default = models.CharField(max_length=1024)
 | 
			
		||||
    icon_best = models.CharField(max_length=1024)
 | 
			
		||||
    user = models.ForeignKey(User, on_delete=models.CASCADE)
 | 
			
		||||
 | 
			
		||||
    # overrides
 | 
			
		||||
    auto_download = models.BooleanField(null=True, blank=True)
 | 
			
		||||
    download_limit = models.IntegerField(null=True, blank=True)
 | 
			
		||||
    download_order = models.CharField(
 | 
			
		||||
        null=True, blank=True,
 | 
			
		||||
        max_length=128,
 | 
			
		||||
        choices=VIDEO_ORDER_CHOICES)
 | 
			
		||||
    delete_after_watched = models.BooleanField(null=True, blank=True)
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
 | 
			
		||||
    def __repr__(self):
 | 
			
		||||
        return f'subscription {self.id}, name="{self.name}", playlist_id="{self.playlist_id}"'
 | 
			
		||||
 | 
			
		||||
    def fill_from_playlist(self, info_playlist: youtube.Playlist):
 | 
			
		||||
        self.name = info_playlist.title
 | 
			
		||||
        self.playlist_id = info_playlist.id
 | 
			
		||||
        self.description = info_playlist.description
 | 
			
		||||
        self.channel_id = info_playlist.channel_id
 | 
			
		||||
        self.channel_name = info_playlist.channel_title
 | 
			
		||||
        self.icon_default = youtube.default_thumbnail(info_playlist).url
 | 
			
		||||
        self.icon_best = youtube.best_thumbnail(info_playlist).url
 | 
			
		||||
 | 
			
		||||
    def copy_from_channel(self, info_channel: youtube.Channel):
 | 
			
		||||
        # No point in storing info about the 'uploads from X' playlist
 | 
			
		||||
        self.name = info_channel.title
 | 
			
		||||
        self.playlist_id = info_channel.uploads_playlist.id
 | 
			
		||||
        self.description = info_channel.description
 | 
			
		||||
        self.channel_id = info_channel.id
 | 
			
		||||
        self.channel_name = info_channel.title
 | 
			
		||||
        self.icon_default = youtube.default_thumbnail(info_channel).url
 | 
			
		||||
        self.icon_best = youtube.best_thumbnail(info_channel).url
 | 
			
		||||
 | 
			
		||||
    def fetch_from_url(self, url, yt_api: youtube.YoutubeAPI):
 | 
			
		||||
        url_parsed = yt_api.parse_url(url)
 | 
			
		||||
        if 'playlist' in url_parsed:
 | 
			
		||||
            info_playlist = yt_api.playlist(url=url)
 | 
			
		||||
            if info_playlist is None:
 | 
			
		||||
                raise ValueError('Invalid playlist ID!')
 | 
			
		||||
 | 
			
		||||
            self.fill_from_playlist(info_playlist)
 | 
			
		||||
        else:
 | 
			
		||||
            info_channel = yt_api.channel(url=url)
 | 
			
		||||
            if info_channel is None:
 | 
			
		||||
                raise ValueError('Cannot find channel!')
 | 
			
		||||
 | 
			
		||||
            self.copy_from_channel(info_channel)
 | 
			
		||||
 | 
			
		||||
    def delete_subscription(self, keep_downloaded_videos: bool):
 | 
			
		||||
        self.delete()
 | 
			
		||||
 | 
			
		||||
    def get_overloads_dict(self) -> dict:
 | 
			
		||||
        d = {}
 | 
			
		||||
        if self.auto_download is not None:
 | 
			
		||||
            d['AutoDownload'] = self.auto_download
 | 
			
		||||
        if self.download_limit is not None:
 | 
			
		||||
            d['DownloadSubscriptionLimit'] = self.download_limit
 | 
			
		||||
        if self.download_order is not None:
 | 
			
		||||
            d['DownloadOrder'] = self.download_order
 | 
			
		||||
        if self.delete_after_watched is not None:
 | 
			
		||||
            d['DeleteWatched'] = self.delete_after_watched
 | 
			
		||||
        return d
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Video(models.Model):
 | 
			
		||||
    video_id = models.TextField(null=False)
 | 
			
		||||
    name = models.TextField(null=False)
 | 
			
		||||
    description = models.TextField()
 | 
			
		||||
    watched = models.BooleanField(default=False, null=False)
 | 
			
		||||
    downloaded_path = models.TextField(null=True, blank=True)
 | 
			
		||||
    subscription = models.ForeignKey(Subscription, on_delete=models.CASCADE)
 | 
			
		||||
    playlist_index = models.IntegerField(null=False)
 | 
			
		||||
    publish_date = models.DateTimeField(null=False)
 | 
			
		||||
    icon_default = models.TextField()
 | 
			
		||||
    icon_best = models.TextField()
 | 
			
		||||
    uploader_name = models.TextField(null=False)
 | 
			
		||||
    views = models.IntegerField(null=False, default=0)
 | 
			
		||||
    rating = models.FloatField(null=False, default=0.5)
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def create(playlist_item: youtube.PlaylistItem, subscription: Subscription):
 | 
			
		||||
        video = Video()
 | 
			
		||||
        video.video_id = playlist_item.resource_video_id
 | 
			
		||||
        video.name = playlist_item.title
 | 
			
		||||
        video.description = playlist_item.description
 | 
			
		||||
        video.watched = False
 | 
			
		||||
        video.downloaded_path = None
 | 
			
		||||
        video.subscription = subscription
 | 
			
		||||
        video.playlist_index = playlist_item.position
 | 
			
		||||
        video.publish_date = playlist_item.published_at
 | 
			
		||||
        video.icon_default = youtube.default_thumbnail(playlist_item).url
 | 
			
		||||
        video.icon_best = youtube.best_thumbnail(playlist_item).url
 | 
			
		||||
        video.save()
 | 
			
		||||
        return video
 | 
			
		||||
 | 
			
		||||
    def mark_watched(self):
 | 
			
		||||
        self.watched = True
 | 
			
		||||
        self.save()
 | 
			
		||||
        if self.downloaded_path is not None:
 | 
			
		||||
            from YtManagerApp.appconfig import settings
 | 
			
		||||
            from YtManagerApp.management.jobs.delete_video import schedule_delete_video
 | 
			
		||||
            from YtManagerApp.management.jobs.synchronize import schedule_synchronize_now_subscription
 | 
			
		||||
 | 
			
		||||
            if settings.getboolean_sub(self.subscription, 'user', 'DeleteWatched'):
 | 
			
		||||
                schedule_delete_video(self)
 | 
			
		||||
                schedule_synchronize_now_subscription(self.subscription)
 | 
			
		||||
 | 
			
		||||
    def mark_unwatched(self):
 | 
			
		||||
        from YtManagerApp.management.jobs.synchronize import schedule_synchronize_now_subscription
 | 
			
		||||
        self.watched = False
 | 
			
		||||
        self.save()
 | 
			
		||||
        schedule_synchronize_now_subscription(self.subscription)
 | 
			
		||||
 | 
			
		||||
    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 delete_files(self):
 | 
			
		||||
        if self.downloaded_path is not None:
 | 
			
		||||
            from YtManagerApp.management.jobs.delete_video import schedule_delete_video
 | 
			
		||||
            from YtManagerApp.appconfig import settings
 | 
			
		||||
            from YtManagerApp.management.jobs.synchronize import schedule_synchronize_now_subscription
 | 
			
		||||
 | 
			
		||||
            schedule_delete_video(self)
 | 
			
		||||
 | 
			
		||||
            # Mark watched?
 | 
			
		||||
            if settings.getboolean_sub(self, 'user', 'MarkDeletedAsWatched'):
 | 
			
		||||
                self.watched = True
 | 
			
		||||
                schedule_synchronize_now_subscription(self.subscription)
 | 
			
		||||
 | 
			
		||||
    def download(self):
 | 
			
		||||
        if not self.downloaded_path:
 | 
			
		||||
            from YtManagerApp.management.jobs.download_video import schedule_download_video
 | 
			
		||||
            schedule_download_video(self)
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
 | 
			
		||||
    def __repr__(self):
 | 
			
		||||
        return f'video {self.id}, video_id="{self.video_id}"'
 | 
			
		||||
							
								
								
									
										24
									
								
								app/YtManagerApp/scheduler.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								app/YtManagerApp/scheduler.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
			
		||||
import logging
 | 
			
		||||
import sys
 | 
			
		||||
from apscheduler.schedulers.background import BackgroundScheduler
 | 
			
		||||
 | 
			
		||||
scheduler: BackgroundScheduler = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def initialize_scheduler():
 | 
			
		||||
    from .appconfig import settings
 | 
			
		||||
    global scheduler
 | 
			
		||||
 | 
			
		||||
    logger = logging.getLogger('scheduler')
 | 
			
		||||
    executors = {
 | 
			
		||||
        'default': {
 | 
			
		||||
            'type': 'threadpool',
 | 
			
		||||
            'max_workers': settings.getint('global', 'SchedulerConcurrency')
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    job_defaults = {
 | 
			
		||||
        'misfire_grace_time': 60 * 60 * 24 * 365        # 1 year
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    scheduler = BackgroundScheduler(logger=logger, executors=executors, job_defaults=job_defaults)
 | 
			
		||||
    scheduler.start()
 | 
			
		||||
							
								
								
									
										9
									
								
								app/YtManagerApp/static/YtManagerApp/css/login.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/YtManagerApp/static/YtManagerApp/css/login.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
.login-card {
 | 
			
		||||
  width: 26rem;
 | 
			
		||||
  margin: 2rem 0; }
 | 
			
		||||
 | 
			
		||||
.register-card {
 | 
			
		||||
  max-width: 35rem;
 | 
			
		||||
  margin: 2rem 0; }
 | 
			
		||||
 | 
			
		||||
/*# sourceMappingURL=login.css.map */
 | 
			
		||||
							
								
								
									
										7
									
								
								app/YtManagerApp/static/YtManagerApp/css/login.css.map
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								app/YtManagerApp/static/YtManagerApp/css/login.css.map
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
"version": 3,
 | 
			
		||||
"mappings": "AAAA,WAAY;EACR,KAAK,EAAE,KAAK;EACZ,MAAM,EAAE,MAAM;;AAGlB,cAAe;EACX,SAAS,EAAE,KAAK;EAChB,MAAM,EAAE,MAAM",
 | 
			
		||||
"sources": ["login.scss"],
 | 
			
		||||
"names": [],
 | 
			
		||||
"file": "login.css"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										9
									
								
								app/YtManagerApp/static/YtManagerApp/css/login.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/YtManagerApp/static/YtManagerApp/css/login.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
.login-card {
 | 
			
		||||
    max-width: 26rem;
 | 
			
		||||
    margin: 2rem 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.register-card {
 | 
			
		||||
    max-width: 35rem;
 | 
			
		||||
    margin: 2rem 0;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										122
									
								
								app/YtManagerApp/static/YtManagerApp/css/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								app/YtManagerApp/static/YtManagerApp/css/style.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,122 @@
 | 
			
		||||
#main_body {
 | 
			
		||||
  margin-bottom: 4rem; }
 | 
			
		||||
 | 
			
		||||
#main_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;
 | 
			
		||||
  width: 64px;
 | 
			
		||||
  height: 64px; }
 | 
			
		||||
  .loading-dual-ring:after {
 | 
			
		||||
    content: " ";
 | 
			
		||||
    display: block;
 | 
			
		||||
    width: 46px;
 | 
			
		||||
    height: 46px;
 | 
			
		||||
    margin: 1px;
 | 
			
		||||
    border-radius: 50%;
 | 
			
		||||
    border: 5px solid #007bff;
 | 
			
		||||
    border-color: #007bff transparent #007bff transparent;
 | 
			
		||||
    animation: loading-dual-ring 1.2s linear infinite; }
 | 
			
		||||
 | 
			
		||||
.loading-dual-ring-small {
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  width: 32px;
 | 
			
		||||
  height: 32px; }
 | 
			
		||||
  .loading-dual-ring-small:after {
 | 
			
		||||
    content: " ";
 | 
			
		||||
    display: block;
 | 
			
		||||
    width: 23px;
 | 
			
		||||
    height: 23px;
 | 
			
		||||
    margin: 1px;
 | 
			
		||||
    border-radius: 50%;
 | 
			
		||||
    border: 2.5px solid #007bff;
 | 
			
		||||
    border-color: #007bff transparent #007bff transparent;
 | 
			
		||||
    animation: loading-dual-ring 1.2s linear infinite; }
 | 
			
		||||
 | 
			
		||||
@keyframes loading-dual-ring {
 | 
			
		||||
  0% {
 | 
			
		||||
    transform: rotate(0deg); }
 | 
			
		||||
  100% {
 | 
			
		||||
    transform: rotate(360deg); } }
 | 
			
		||||
.loading-dual-ring-center-screen {
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  top: 50%;
 | 
			
		||||
  left: 50%;
 | 
			
		||||
  margin-top: -32px;
 | 
			
		||||
  margin-left: -32px; }
 | 
			
		||||
 | 
			
		||||
.black-overlay {
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  /* Sit on top of the page content */
 | 
			
		||||
  display: none;
 | 
			
		||||
  /* Hidden by default */
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  /* Full width (cover the whole page) */
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  /* Full height (cover the whole page) */
 | 
			
		||||
  top: 0;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  right: 0;
 | 
			
		||||
  bottom: 0;
 | 
			
		||||
  background-color: rgba(0, 0, 0, 0.5);
 | 
			
		||||
  /* Black background with opacity */
 | 
			
		||||
  z-index: 2;
 | 
			
		||||
  /* Specify a stack order in case you're using a different order for other elements */
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  /* Add a pointer on hover */ }
 | 
			
		||||
 | 
			
		||||
.video-gallery .card-wrapper {
 | 
			
		||||
  padding: 0.4rem;
 | 
			
		||||
  margin-bottom: .5rem; }
 | 
			
		||||
.video-gallery .card .card-body {
 | 
			
		||||
  padding: .75rem; }
 | 
			
		||||
.video-gallery .card .card-text {
 | 
			
		||||
  font-size: 10pt;
 | 
			
		||||
  margin-bottom: .5rem; }
 | 
			
		||||
.video-gallery .card .card-title {
 | 
			
		||||
  font-size: 11pt;
 | 
			
		||||
  margin-bottom: .5rem; }
 | 
			
		||||
  .video-gallery .card .card-title .badge {
 | 
			
		||||
    font-size: 8pt; }
 | 
			
		||||
.video-gallery .card .card-footer {
 | 
			
		||||
  padding: .5rem .75rem; }
 | 
			
		||||
.video-gallery .card .card-more {
 | 
			
		||||
  margin-right: -0.25rem; }
 | 
			
		||||
  .video-gallery .card .card-more:hover {
 | 
			
		||||
    text-decoration: none; }
 | 
			
		||||
.video-gallery .card .progress {
 | 
			
		||||
  width: 100px; }
 | 
			
		||||
.video-gallery .video-icon-yes {
 | 
			
		||||
  color: #007bff; }
 | 
			
		||||
.video-gallery .video-icon-no {
 | 
			
		||||
  color: #dddddd; }
 | 
			
		||||
 | 
			
		||||
.alert-card {
 | 
			
		||||
  max-width: 35rem;
 | 
			
		||||
  margin: 2rem 0; }
 | 
			
		||||
 | 
			
		||||
.no-asterisk .asteriskField {
 | 
			
		||||
  display: none; }
 | 
			
		||||
 | 
			
		||||
.modal-field-error {
 | 
			
		||||
  margin: 0.5rem 0;
 | 
			
		||||
  padding: 0.5rem 0; }
 | 
			
		||||
  .modal-field-error ul {
 | 
			
		||||
    margin: 0; }
 | 
			
		||||
 | 
			
		||||
.star-rating {
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  margin-bottom: 0.5rem; }
 | 
			
		||||
 | 
			
		||||
/*# sourceMappingURL=style.css.map */
 | 
			
		||||
							
								
								
									
										7
									
								
								app/YtManagerApp/static/YtManagerApp/css/style.css.map
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								app/YtManagerApp/static/YtManagerApp/css/style.css.map
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
"version": 3,
 | 
			
		||||
"mappings": "AAEA,UAAW;EACP,aAAa,EAAE,IAAI;;AAGvB,YAAa;EACT,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;AAO7B,8BAAU;EACN,KAAK,EAAE,KAAK;AAKpB,8BAAgB;EACZ,KAAK,EAvHE,OAAO;AAyHlB,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;;AAIjB,YAAa;EACT,OAAO,EAAE,YAAY;EACrB,aAAa,EAAE,MAAM",
 | 
			
		||||
"sources": ["style.scss"],
 | 
			
		||||
"names": [],
 | 
			
		||||
"file": "style.css"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										150
									
								
								app/YtManagerApp/static/YtManagerApp/css/style.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								app/YtManagerApp/static/YtManagerApp/css/style.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,150 @@
 | 
			
		||||
$accent-color: #007bff;
 | 
			
		||||
 | 
			
		||||
#main_body {
 | 
			
		||||
    margin-bottom: 4rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#main_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;
 | 
			
		||||
    height: $scale * 64px;
 | 
			
		||||
 | 
			
		||||
    &:after {
 | 
			
		||||
        content: " ";
 | 
			
		||||
        display: block;
 | 
			
		||||
        width: $scale * 46px;
 | 
			
		||||
        height: $scale * 46px;
 | 
			
		||||
        margin: 1px;
 | 
			
		||||
        border-radius: 50%;
 | 
			
		||||
        border: ($scale * 5px) solid $accent-color;
 | 
			
		||||
        border-color: $accent-color transparent $accent-color transparent;
 | 
			
		||||
        animation: loading-dual-ring 1.2s linear infinite;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Loading animation */
 | 
			
		||||
.loading-dual-ring {
 | 
			
		||||
    @include loading-dual-ring(1.0);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.loading-dual-ring-small {
 | 
			
		||||
    @include loading-dual-ring(0.5);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes loading-dual-ring {
 | 
			
		||||
    0% {
 | 
			
		||||
        transform: rotate(0deg);
 | 
			
		||||
    }
 | 
			
		||||
    100% {
 | 
			
		||||
        transform: rotate(360deg);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.loading-dual-ring-center-screen {
 | 
			
		||||
    position: fixed;
 | 
			
		||||
    top: 50%;
 | 
			
		||||
    left: 50%;
 | 
			
		||||
    margin-top: -32px;
 | 
			
		||||
    margin-left: -32px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.black-overlay {
 | 
			
		||||
    position: fixed; /* Sit on top of the page content */
 | 
			
		||||
    display: none; /* Hidden by default */
 | 
			
		||||
    width: 100%; /* Full width (cover the whole page) */
 | 
			
		||||
    height: 100%; /* Full height (cover the whole page) */
 | 
			
		||||
    top: 0;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    right: 0;
 | 
			
		||||
    bottom: 0;
 | 
			
		||||
    background-color: rgba(0,0,0,0.5); /* Black background with opacity */
 | 
			
		||||
    z-index: 2; /* Specify a stack order in case you're using a different order for other elements */
 | 
			
		||||
    cursor: pointer; /* Add a pointer on hover */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.video-gallery {
 | 
			
		||||
    .card-wrapper {
 | 
			
		||||
        padding: 0.4rem;
 | 
			
		||||
        margin-bottom: .5rem;
 | 
			
		||||
    }
 | 
			
		||||
    .card {
 | 
			
		||||
        .card-body {
 | 
			
		||||
            padding: .75rem;
 | 
			
		||||
        }
 | 
			
		||||
        .card-text {
 | 
			
		||||
            font-size: 10pt;
 | 
			
		||||
            margin-bottom: .5rem;
 | 
			
		||||
        }
 | 
			
		||||
        .card-title {
 | 
			
		||||
            font-size: 11pt;
 | 
			
		||||
            margin-bottom: .5rem;
 | 
			
		||||
 | 
			
		||||
            .badge {
 | 
			
		||||
                font-size: 8pt;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .card-footer {
 | 
			
		||||
            padding: .5rem .75rem;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .card-more {
 | 
			
		||||
            margin-right: -0.25rem;
 | 
			
		||||
            &:hover {
 | 
			
		||||
                text-decoration: none;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .card-img-top {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .progress {
 | 
			
		||||
            width: 100px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .video-icon-yes {
 | 
			
		||||
        color: $accent-color;
 | 
			
		||||
    }
 | 
			
		||||
    .video-icon-no {
 | 
			
		||||
        color: #dddddd;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.alert-card {
 | 
			
		||||
    max-width: 35rem;
 | 
			
		||||
    margin: 2rem 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.no-asterisk {
 | 
			
		||||
    .asteriskField {
 | 
			
		||||
        display: none;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-field-error {
 | 
			
		||||
    margin: 0.5rem 0;
 | 
			
		||||
    padding: 0.5rem 0;
 | 
			
		||||
 | 
			
		||||
    ul {
 | 
			
		||||
        margin: 0;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.star-rating {
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    margin-bottom: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/><path d="M0 0h24v24H0z" fill="none"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 229 B  | 
@@ -0,0 +1 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/><path d="M0 0h24v24H0z" fill="none"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 247 B  | 
@@ -0,0 +1,92 @@
 | 
			
		||||
Copyright (c) 2014, Stephen Hutchings (http://www.s-ings.com/).
 | 
			
		||||
 | 
			
		||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
 | 
			
		||||
 | 
			
		||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
 | 
			
		||||
 | 
			
		||||
SIL OPEN FONT LICENSE
 | 
			
		||||
 | 
			
		||||
Version 1.1 - 26 February 2007
 | 
			
		||||
 | 
			
		||||
PREAMBLE
 | 
			
		||||
The goals of the Open Font License (OFL) are to stimulate worldwide
 | 
			
		||||
development of collaborative font projects, to support the font creation
 | 
			
		||||
efforts of academic and linguistic communities, and to provide a free and
 | 
			
		||||
open framework in which fonts may be shared and improved in partnership
 | 
			
		||||
with others.
 | 
			
		||||
 | 
			
		||||
The OFL allows the licensed fonts to be used, studied, modified and
 | 
			
		||||
redistributed freely as long as they are not sold by themselves. The
 | 
			
		||||
fonts, including any derivative works, can be bundled, embedded,
 | 
			
		||||
redistributed and/or sold with any software provided that any reserved
 | 
			
		||||
names are not used by derivative works. The fonts and derivatives,
 | 
			
		||||
however, cannot be released under any other type of license. The
 | 
			
		||||
requirement for fonts to remain under this license does not apply
 | 
			
		||||
to any document created using the fonts or their derivatives.
 | 
			
		||||
 | 
			
		||||
DEFINITIONS
 | 
			
		||||
"Font Software" refers to the set of files released by the Copyright
 | 
			
		||||
Holder(s) under this license and clearly marked as such. This may
 | 
			
		||||
include source files, build scripts and documentation.
 | 
			
		||||
 | 
			
		||||
"Reserved Font Name" refers to any names specified as such after the
 | 
			
		||||
copyright statement(s).
 | 
			
		||||
 | 
			
		||||
"Original Version" refers to the collection of Font Software components as
 | 
			
		||||
distributed by the Copyright Holder(s).
 | 
			
		||||
 | 
			
		||||
"Modified Version" refers to any derivative made by adding to, deleting,
 | 
			
		||||
or substituting — in part or in whole — any of the components of the
 | 
			
		||||
Original Version, by changing formats or by porting the Font Software to a
 | 
			
		||||
new environment.
 | 
			
		||||
 | 
			
		||||
"Author" refers to any designer, engineer, programmer, technical
 | 
			
		||||
writer or other person who contributed to the Font Software.
 | 
			
		||||
 | 
			
		||||
PERMISSION & CONDITIONS
 | 
			
		||||
Permission is hereby granted, free of charge, to any person obtaining
 | 
			
		||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
 | 
			
		||||
redistribute, and sell modified and unmodified copies of the Font
 | 
			
		||||
Software, subject to the following conditions:
 | 
			
		||||
 | 
			
		||||
1) Neither the Font Software nor any of its individual components,
 | 
			
		||||
in Original or Modified Versions, may be sold by itself.
 | 
			
		||||
 | 
			
		||||
2) Original or Modified Versions of the Font Software may be bundled,
 | 
			
		||||
redistributed and/or sold with any software, provided that each copy
 | 
			
		||||
contains the above copyright notice and this license. These can be
 | 
			
		||||
included either as stand-alone text files, human-readable headers or
 | 
			
		||||
in the appropriate machine-readable metadata fields within text or
 | 
			
		||||
binary files as long as those fields can be easily viewed by the user.
 | 
			
		||||
 | 
			
		||||
3) No Modified Version of the Font Software may use the Reserved Font
 | 
			
		||||
Name(s) unless explicit written permission is granted by the corresponding
 | 
			
		||||
Copyright Holder. This restriction only applies to the primary font name as
 | 
			
		||||
presented to the users.
 | 
			
		||||
 | 
			
		||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
 | 
			
		||||
Software shall not be used to promote, endorse or advertise any
 | 
			
		||||
Modified Version, except to acknowledge the contribution(s) of the
 | 
			
		||||
Copyright Holder(s) and the Author(s) or with their explicit written
 | 
			
		||||
permission.
 | 
			
		||||
 | 
			
		||||
5) The Font Software, modified or unmodified, in part or in whole,
 | 
			
		||||
must be distributed entirely under this license, and must not be
 | 
			
		||||
distributed under any other license. The requirement for fonts to
 | 
			
		||||
remain under this license does not apply to any document created
 | 
			
		||||
using the Font Software.
 | 
			
		||||
 | 
			
		||||
TERMINATION
 | 
			
		||||
This license becomes null and void if any of the above conditions are
 | 
			
		||||
not met.
 | 
			
		||||
 | 
			
		||||
DISCLAIMER
 | 
			
		||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 | 
			
		||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
 | 
			
		||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
 | 
			
		||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
 | 
			
		||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
 | 
			
		||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
 | 
			
		||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 | 
			
		||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
 | 
			
		||||
OTHER DEALINGS IN THE FONT SOFTWARE.
 | 
			
		||||
							
								
								
									
										406
									
								
								app/YtManagerApp/static/YtManagerApp/import/typicons/demo.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										406
									
								
								app/YtManagerApp/static/YtManagerApp/import/typicons/demo.html
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1040
									
								
								app/YtManagerApp/static/YtManagerApp/import/typicons/typicons.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1040
									
								
								app/YtManagerApp/static/YtManagerApp/import/typicons/typicons.css
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										1
									
								
								app/YtManagerApp/static/YtManagerApp/import/typicons/typicons.min.css
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								app/YtManagerApp/static/YtManagerApp/import/typicons/typicons.min.css
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1180
									
								
								app/YtManagerApp/static/YtManagerApp/import/typicons/typicons.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1180
									
								
								app/YtManagerApp/static/YtManagerApp/import/typicons/typicons.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| 
		 After Width: | Height: | Size: 182 KiB  | 
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
{% extends 'YtManagerApp/controls/modal.html' %}
 | 
			
		||||
{% load crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block modal_title %}
 | 
			
		||||
    New folder
 | 
			
		||||
{% endblock modal_title %}
 | 
			
		||||
 | 
			
		||||
{% block modal_content %}
 | 
			
		||||
    <form action="{% url 'modal_create_folder' %}" method="post">
 | 
			
		||||
        {{ block.super }}
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block modal_body %}
 | 
			
		||||
    {% crispy form %}
 | 
			
		||||
{% endblock modal_body %}
 | 
			
		||||
 | 
			
		||||
{% block modal_footer %}
 | 
			
		||||
    <input class="btn btn-primary" type="submit" value="Create">
 | 
			
		||||
    <input class="btn btn-secondary" type="button" value="Cancel" data-dismiss="modal" aria-label="Cancel">
 | 
			
		||||
{% endblock modal_footer %}
 | 
			
		||||
@@ -0,0 +1,23 @@
 | 
			
		||||
{% extends 'YtManagerApp/controls/modal.html' %}
 | 
			
		||||
{% load crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block modal_title %}
 | 
			
		||||
    Delete folder
 | 
			
		||||
{% endblock modal_title %}
 | 
			
		||||
 | 
			
		||||
{% block modal_content %}
 | 
			
		||||
    <form action="{% url 'modal_delete_folder' object.id %}" method="post">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
        {{ block.super }}
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block modal_body %}
 | 
			
		||||
    <p>Are you sure you want to delete folder "{{ object }}" and all its subfolders?</p>
 | 
			
		||||
    {{ form | crispy }}
 | 
			
		||||
{% endblock modal_body %}
 | 
			
		||||
 | 
			
		||||
{% block modal_footer %}
 | 
			
		||||
    <input class="btn btn-danger" type="submit" value="Delete" aria-label="Delete">
 | 
			
		||||
    <input class="btn btn-secondary" type="button" value="Cancel" data-dismiss="modal" aria-label="Cancel">
 | 
			
		||||
{% endblock modal_footer %}
 | 
			
		||||
@@ -0,0 +1,21 @@
 | 
			
		||||
{% extends 'YtManagerApp/controls/modal.html' %}
 | 
			
		||||
{% load crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block modal_title %}
 | 
			
		||||
    Edit folder
 | 
			
		||||
{% endblock modal_title %}
 | 
			
		||||
 | 
			
		||||
{% block modal_content %}
 | 
			
		||||
    <form action="{% url 'modal_update_folder' form.instance.pk %}" method="post">
 | 
			
		||||
        {{ block.super }}
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block modal_body %}
 | 
			
		||||
    {% crispy form %}
 | 
			
		||||
{% endblock modal_body %}
 | 
			
		||||
 | 
			
		||||
{% block modal_footer %}
 | 
			
		||||
    <input class="btn btn-primary" type="submit" value="Save" aria-label="Save">
 | 
			
		||||
    <input class="btn btn-secondary" type="button" value="Cancel" data-dismiss="modal" aria-label="Cancel">
 | 
			
		||||
{% endblock modal_footer %}
 | 
			
		||||
							
								
								
									
										43
									
								
								app/YtManagerApp/templates/YtManagerApp/controls/modal.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								app/YtManagerApp/templates/YtManagerApp/controls/modal.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,43 @@
 | 
			
		||||
{% block modal_stylesheets %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
<div id="{{ modal_id }}" class="modal {{ modal_classes }}" tabindex="-1" role="dialog">
 | 
			
		||||
    <div class="modal-dialog {{ modal_dialog_classes }}" role="document">
 | 
			
		||||
        <div class="modal-content">
 | 
			
		||||
            {% block modal_content %}
 | 
			
		||||
 | 
			
		||||
                {% block modal_header_wrapper %}
 | 
			
		||||
                    <div class="modal-header">
 | 
			
		||||
                        {% block modal_header %}
 | 
			
		||||
                            <h5 id="{{ modal_id }}_Title" class="modal-title">
 | 
			
		||||
                                {% block modal_title %}{{ modal_title }}{% endblock modal_title %}
 | 
			
		||||
                            </h5>
 | 
			
		||||
                            <button type="button" class="close" data-dismiss="modal" aria-label="Close">
 | 
			
		||||
                                <span aria-hidden="true">×</span>
 | 
			
		||||
                            </button>
 | 
			
		||||
                        {% endblock modal_header %}
 | 
			
		||||
                    </div>
 | 
			
		||||
                {% endblock modal_header_wrapper %}
 | 
			
		||||
 | 
			
		||||
                {% block modal_body_wrapper %}
 | 
			
		||||
                    <div class="modal-body">
 | 
			
		||||
                        {% block modal_body %}
 | 
			
		||||
                        {% endblock %}
 | 
			
		||||
                    </div>
 | 
			
		||||
                {% endblock modal_body_wrapper %}
 | 
			
		||||
 | 
			
		||||
                {% block modal_footer_wrapper %}
 | 
			
		||||
                    <div class="modal-footer">
 | 
			
		||||
                        <div id="modal-loading-ring" class="loading-dual-ring-small mr-auto" style="display: none;"></div>
 | 
			
		||||
                        {% block modal_footer %}
 | 
			
		||||
                        {% endblock modal_footer %}
 | 
			
		||||
                    </div>
 | 
			
		||||
                {% endblock modal_footer_wrapper %}
 | 
			
		||||
 | 
			
		||||
            {% endblock modal_content %}
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
{% block modal_scripts %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -0,0 +1,21 @@
 | 
			
		||||
{% extends 'YtManagerApp/controls/modal.html' %}
 | 
			
		||||
{% load crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block modal_title %}
 | 
			
		||||
    New subscription
 | 
			
		||||
{% endblock modal_title %}
 | 
			
		||||
 | 
			
		||||
{% block modal_content %}
 | 
			
		||||
    <form action="{% url 'modal_create_subscription' %}" method="post">
 | 
			
		||||
        {{ block.super }}
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block modal_body %}
 | 
			
		||||
    {% crispy form %}
 | 
			
		||||
{% endblock modal_body %}
 | 
			
		||||
 | 
			
		||||
{% block modal_footer %}
 | 
			
		||||
    <input class="btn btn-primary" type="submit" value="Create">
 | 
			
		||||
    <input class="btn btn-secondary" type="button" value="Cancel" data-dismiss="modal" aria-label="Cancel">
 | 
			
		||||
{% endblock modal_footer %}
 | 
			
		||||
@@ -0,0 +1,23 @@
 | 
			
		||||
{% extends 'YtManagerApp/controls/modal.html' %}
 | 
			
		||||
{% load crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block modal_title %}
 | 
			
		||||
    Delete subscription
 | 
			
		||||
{% endblock modal_title %}
 | 
			
		||||
 | 
			
		||||
{% block modal_content %}
 | 
			
		||||
    <form action="{% url 'modal_delete_subscription' object.id %}" method="post">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
        {{ block.super }}
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block modal_body %}
 | 
			
		||||
    <p>Are you sure you want to delete subscription "{{ object }}"?</p>
 | 
			
		||||
    {{ form | crispy }}
 | 
			
		||||
{% endblock modal_body %}
 | 
			
		||||
 | 
			
		||||
{% block modal_footer %}
 | 
			
		||||
    <input class="btn btn-danger" type="submit" value="Delete" aria-label="Delete">
 | 
			
		||||
    <input class="btn btn-secondary" type="button" value="Cancel" data-dismiss="modal" aria-label="Cancel">
 | 
			
		||||
{% endblock modal_footer %}
 | 
			
		||||
@@ -0,0 +1,21 @@
 | 
			
		||||
{% extends 'YtManagerApp/controls/modal.html' %}
 | 
			
		||||
{% load crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block modal_title %}
 | 
			
		||||
    Edit subscription
 | 
			
		||||
{% endblock modal_title %}
 | 
			
		||||
 | 
			
		||||
{% block modal_content %}
 | 
			
		||||
    <form action="{% url 'modal_update_subscription' form.instance.pk %}" method="post">
 | 
			
		||||
        {{ block.super }}
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block modal_body %}
 | 
			
		||||
    {% crispy form %}
 | 
			
		||||
{% endblock modal_body %}
 | 
			
		||||
 | 
			
		||||
{% block modal_footer %}
 | 
			
		||||
    <input class="btn btn-primary" type="submit" value="Save" aria-label="Save">
 | 
			
		||||
    <input class="btn btn-secondary" type="button" value="Cancel" data-dismiss="modal" aria-label="Cancel">
 | 
			
		||||
{% endblock modal_footer %}
 | 
			
		||||
							
								
								
									
										74
									
								
								app/YtManagerApp/templates/YtManagerApp/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								app/YtManagerApp/templates/YtManagerApp/index.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,74 @@
 | 
			
		||||
{% extends "YtManagerApp/master_default.html" %}
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block stylesheets %}
 | 
			
		||||
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jstree/3.2.1/themes/default/style.min.css" />
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block scripts %}
 | 
			
		||||
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jstree/3.2.1/jstree.min.js"></script>
 | 
			
		||||
    <script>
 | 
			
		||||
        {% include 'YtManagerApp/js/index.js' %}
 | 
			
		||||
    </script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
 | 
			
		||||
    <div id="modal-wrapper">
 | 
			
		||||
        <div id="modal-loading" class="black-overlay">
 | 
			
		||||
            <div class="loading-dual-ring loading-dual-ring-center-screen"></div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div id="modal-wrapper">
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="row">
 | 
			
		||||
 | 
			
		||||
        <div class="col-3">
 | 
			
		||||
            {# Tree toolbar #}
 | 
			
		||||
            <div class="btn-toolbar" role="toolbar" aria-label="Subscriptions toolbar">
 | 
			
		||||
                <div class="btn-group btn-group-sm mr-2" role="group">
 | 
			
		||||
                    <button id="btn_create_sub" type="button" class="btn btn-secondary" >
 | 
			
		||||
                        <span class="typcn typcn-plus" aria-hidden="true"></span>
 | 
			
		||||
                    </button>
 | 
			
		||||
                    <button id="btn_create_folder" type="button" class="btn btn-secondary">
 | 
			
		||||
                        <span class="typcn typcn-folder-add" aria-hidden="true"></span>
 | 
			
		||||
                    </button>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="btn-group btn-group-sm mr-2" role="group">
 | 
			
		||||
                    <button id="btn_edit_node" type="button" class="btn btn-secondary" >
 | 
			
		||||
                        <span class="typcn typcn-edit" aria-hidden="true"></span>
 | 
			
		||||
                    </button>
 | 
			
		||||
                    <button id="btn_delete_node" type="button" class="btn btn-secondary" >
 | 
			
		||||
                        <span class="typcn typcn-trash" aria-hidden="true"></span>
 | 
			
		||||
                    </button>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div id="tree-wrapper">
 | 
			
		||||
                <div class="d-flex">
 | 
			
		||||
                    <div class="loading-dual-ring mx-auto my-5"></div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div class="col-9">
 | 
			
		||||
            {# Video toolbar #}
 | 
			
		||||
            <div class="btn-toolbar" role="toolbar" aria-label="Subscriptions toolbar">
 | 
			
		||||
                {% crispy filter_form %}
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div id="videos-wrapper">
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div id="videos-loading" style="display: none">
 | 
			
		||||
                <div class="d-flex">
 | 
			
		||||
                    <div class="loading-dual-ring mx-auto my-5"></div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -0,0 +1,9 @@
 | 
			
		||||
{% extends "YtManagerApp/master_default.html" %}
 | 
			
		||||
{% load static %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
 | 
			
		||||
    <h1>Hello</h1>
 | 
			
		||||
    <h2>Please log in to continue</h2>
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										61
									
								
								app/YtManagerApp/templates/YtManagerApp/index_videos.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								app/YtManagerApp/templates/YtManagerApp/index_videos.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,61 @@
 | 
			
		||||
{% load humanize %}
 | 
			
		||||
{% load ratings %}
 | 
			
		||||
 | 
			
		||||
<div class="video-gallery container-fluid">
 | 
			
		||||
    <div class="row">
 | 
			
		||||
        {% for video in videos %}
 | 
			
		||||
            <div class="card-wrapper col-12 col-sm-6 col-lg-4 col-xl-3 d-flex align-items-stretch">
 | 
			
		||||
                <div class="card mx-auto">
 | 
			
		||||
                    <img class="card-img-top" src="{{ video.icon_best }}" alt="Thumbnail">
 | 
			
		||||
                    <div class="card-body">
 | 
			
		||||
                        <h5 class="card-title">
 | 
			
		||||
                            {% if not video.watched %}
 | 
			
		||||
                            <sup class="badge badge-primary">New</sup>
 | 
			
		||||
                            {% endif %}
 | 
			
		||||
                            {{ video.name }}
 | 
			
		||||
                        </h5>
 | 
			
		||||
                        <p class="card-text small text-muted">
 | 
			
		||||
                            <span>{{ video.views | intcomma }} views</span>
 | 
			
		||||
                            <span>•</span>
 | 
			
		||||
                            <span>{{ video.publish_date | naturaltime }}</span>
 | 
			
		||||
                        </p>
 | 
			
		||||
                        <p class="card-text">{{ video.description | truncatechars:120 }}</p>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="card-footer dropdown show">
 | 
			
		||||
                        <span class="typcn typcn-eye {{ video.watched | yesno:"video-icon-yes,video-icon-no" }}"
 | 
			
		||||
                              title="{{ video.watched | yesno:"Watched,Not watched" }}"></span>
 | 
			
		||||
                        <span class="typcn typcn-download {{ video.downloaded_path | yesno:"video-icon-yes,,video-icon-no" }}"
 | 
			
		||||
                              title="{{ video.downloaded_path | yesno:"Downloaded,,Not downloaded" }}"></span>
 | 
			
		||||
                        <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>
 | 
			
		||||
                        <div class="dropdown-menu" aria-labelledby="dropdownMenuLink">
 | 
			
		||||
                            {% 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 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>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										185
									
								
								app/YtManagerApp/templates/YtManagerApp/js/common.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								app/YtManagerApp/templates/YtManagerApp/js/common.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,185 @@
 | 
			
		||||
class AjaxModal
 | 
			
		||||
{
 | 
			
		||||
    constructor(url)
 | 
			
		||||
    {
 | 
			
		||||
        this.wrapper = $("#modal-wrapper");
 | 
			
		||||
        this.loading = $("#modal-loading");
 | 
			
		||||
        this.url = url;
 | 
			
		||||
        this.modal = null;
 | 
			
		||||
        this.form = null;
 | 
			
		||||
        this.submitCallback = null;
 | 
			
		||||
        this.modalLoadingRing = null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setSubmitCallback(callback) {
 | 
			
		||||
        this.submitCallback = callback;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _showLoading() {
 | 
			
		||||
        this.loading.fadeIn(500);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _hideLoading() {
 | 
			
		||||
        this.loading.fadeOut(100);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _showModal() {
 | 
			
		||||
        if (this.modal != null)
 | 
			
		||||
            this.modal.modal();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _hideModal() {
 | 
			
		||||
        if (this.modal != null)
 | 
			
		||||
            this.modal.modal('hide');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _load(result) {
 | 
			
		||||
        this.wrapper.html(result);
 | 
			
		||||
 | 
			
		||||
        this.modal = this.wrapper.find('.modal');
 | 
			
		||||
        this.form = this.wrapper.find('form');
 | 
			
		||||
        this.modalLoadingRing = this.wrapper.find('#modal-loading-ring');
 | 
			
		||||
 | 
			
		||||
        let pThis = this;
 | 
			
		||||
        this.form.submit(function(e) {
 | 
			
		||||
            pThis._submit(e);
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _loadFailed() {
 | 
			
		||||
        this.wrapper.html('<div class="alert alert-danger">An error occurred while displaying the dialog!</div>');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _submit(e) {
 | 
			
		||||
        let pThis = this;
 | 
			
		||||
        let url = this.form.attr('action');
 | 
			
		||||
        $.post(url, this.form.serialize())
 | 
			
		||||
            .done(function(result) {
 | 
			
		||||
                pThis._submitDone(result);
 | 
			
		||||
            })
 | 
			
		||||
            .fail(function() {
 | 
			
		||||
                pThis._submitFailed();
 | 
			
		||||
            })
 | 
			
		||||
            .always(function() {
 | 
			
		||||
                pThis.modalLoadingRing.fadeOut(100);
 | 
			
		||||
                pThis.wrapper.find(":input").prop("disabled", false);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
        this.modalLoadingRing.fadeIn(200);
 | 
			
		||||
        this.wrapper.find(":input").prop("disabled", true);
 | 
			
		||||
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _submitDone(result) {
 | 
			
		||||
        // Clear old errors first
 | 
			
		||||
        this.form.find('.modal-field-error').remove();
 | 
			
		||||
 | 
			
		||||
        if (!result.hasOwnProperty('success')) {
 | 
			
		||||
            this._submitInvalidResponse();
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (result.success) {
 | 
			
		||||
            this._hideModal();
 | 
			
		||||
            if (this.submitCallback != null)
 | 
			
		||||
                this.submitCallback();
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
            if (!result.hasOwnProperty('errors')) {
 | 
			
		||||
                this._submitInvalidResponse();
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            for (let field in result.errors)
 | 
			
		||||
                if (result.errors.hasOwnProperty(field))
 | 
			
		||||
                {
 | 
			
		||||
                    let errorsArray = result.errors[field];
 | 
			
		||||
                    let errorsConcat = "<div class=\"alert alert-danger modal-field-error\"><ul>";
 | 
			
		||||
 | 
			
		||||
                    for(let error of errorsArray) {
 | 
			
		||||
                        errorsConcat += `<li>${error.message}</li>`;
 | 
			
		||||
                    }
 | 
			
		||||
                    errorsConcat += '</ul></div>';
 | 
			
		||||
 | 
			
		||||
                    if (field === '__all__')
 | 
			
		||||
                        this.form.find('.modal-body').append(errorsConcat);
 | 
			
		||||
                    else
 | 
			
		||||
                        this.form.find(`[name='${field}']`).after(errorsConcat);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
            let errorsHtml = '';
 | 
			
		||||
 | 
			
		||||
            let err = this.modal.find('#__modal_error');
 | 
			
		||||
            if (err.length) {
 | 
			
		||||
                err.html('An error occurred');
 | 
			
		||||
            }
 | 
			
		||||
            else {
 | 
			
		||||
                this.modal.find('.modal-body').append(errorsHtml)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _submitFailed() {
 | 
			
		||||
        // Clear old errors first
 | 
			
		||||
        this.form.find('.modal-field-error').remove();
 | 
			
		||||
        this.form.find('.modal-body')
 | 
			
		||||
            .append(`<div class="alert alert-danger modal-field-error">An error occurred while processing request!</div>`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _submitInvalidResponse() {
 | 
			
		||||
        // Clear old errors first
 | 
			
		||||
        this.form.find('.modal-field-error').remove();
 | 
			
		||||
        this.form.find('.modal-body')
 | 
			
		||||
            .append(`<div class="alert alert-danger modal-field-error">Invalid server response!</div>`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    loadAndShow()
 | 
			
		||||
    {
 | 
			
		||||
        let pThis = this;
 | 
			
		||||
        this._showLoading();
 | 
			
		||||
 | 
			
		||||
        $.get(this.url)
 | 
			
		||||
            .done(function (result) {
 | 
			
		||||
                pThis._load(result);
 | 
			
		||||
                pThis._showModal();
 | 
			
		||||
            })
 | 
			
		||||
            .fail(function () {
 | 
			
		||||
                pThis._loadFailed();
 | 
			
		||||
            })
 | 
			
		||||
            .always(function() {
 | 
			
		||||
                pThis._hideLoading();
 | 
			
		||||
            });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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);
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										189
									
								
								app/YtManagerApp/templates/YtManagerApp/js/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								app/YtManagerApp/templates/YtManagerApp/js/index.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,189 @@
 | 
			
		||||
function treeNode_Edit()
 | 
			
		||||
{
 | 
			
		||||
    let selectedNodes = $("#tree-wrapper").jstree('get_selected', true);
 | 
			
		||||
    if (selectedNodes.length === 1)
 | 
			
		||||
    {
 | 
			
		||||
        let node = selectedNodes[0];
 | 
			
		||||
 | 
			
		||||
        if (node.type === 'folder') {
 | 
			
		||||
            let id = node.id.replace('folder', '');
 | 
			
		||||
            let modal = new AjaxModal("{% url 'modal_update_folder' 98765 %}".replace('98765', id));
 | 
			
		||||
            modal.setSubmitCallback(tree_Refresh);
 | 
			
		||||
            modal.loadAndShow();
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
            let id = node.id.replace('sub', '');
 | 
			
		||||
            let modal = new AjaxModal("{% url 'modal_update_subscription' 98765 %}".replace('98765', id));
 | 
			
		||||
            modal.setSubmitCallback(tree_Refresh);
 | 
			
		||||
            modal.loadAndShow();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function treeNode_Delete()
 | 
			
		||||
{
 | 
			
		||||
    let selectedNodes = $("#tree-wrapper").jstree('get_selected', true);
 | 
			
		||||
    if (selectedNodes.length === 1)
 | 
			
		||||
    {
 | 
			
		||||
        let node = selectedNodes[0];
 | 
			
		||||
 | 
			
		||||
        if (node.type === 'folder') {
 | 
			
		||||
            let id = node.id.replace('folder', '');
 | 
			
		||||
            let modal = new AjaxModal("{% url 'modal_delete_folder' 98765 %}".replace('98765', id));
 | 
			
		||||
            modal.setSubmitCallback(tree_Refresh);
 | 
			
		||||
            modal.loadAndShow();
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
            let id = node.id.replace('sub', '');
 | 
			
		||||
            let modal = new AjaxModal("{% url 'modal_delete_subscription' 98765 %}".replace('98765', id));
 | 
			
		||||
            modal.setSubmitCallback(tree_Refresh);
 | 
			
		||||
            modal.loadAndShow();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function tree_Initialize()
 | 
			
		||||
{
 | 
			
		||||
    let treeWrapper = $("#tree-wrapper");
 | 
			
		||||
    treeWrapper.jstree({
 | 
			
		||||
        core : {
 | 
			
		||||
            data : {
 | 
			
		||||
                url : "{% url 'ajax_get_tree' %}"
 | 
			
		||||
            },
 | 
			
		||||
            check_callback : tree_ValidateChange,
 | 
			
		||||
            themes : {
 | 
			
		||||
                dots : false
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
        types : {
 | 
			
		||||
            folder : {
 | 
			
		||||
                icon : "typcn typcn-folder"
 | 
			
		||||
            },
 | 
			
		||||
            sub : {
 | 
			
		||||
                icon : "typcn typcn-user",
 | 
			
		||||
                max_depth : 0
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        plugins : [ "types", "wholerow", "dnd" ]
 | 
			
		||||
    });
 | 
			
		||||
    treeWrapper.on("changed.jstree", tree_OnSelectionChanged);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function tree_Refresh()
 | 
			
		||||
{
 | 
			
		||||
    $("#tree-wrapper").jstree("refresh");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function tree_ValidateChange(operation, node, parent, position, more)
 | 
			
		||||
{
 | 
			
		||||
    if (more.dnd)
 | 
			
		||||
    {
 | 
			
		||||
        // create_node, rename_node, delete_node, move_node and copy_node
 | 
			
		||||
        if (operation === "copy_node" || operation === "move_node")
 | 
			
		||||
        {
 | 
			
		||||
            if (more.ref.type === "sub")
 | 
			
		||||
                return false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function tree_OnSelectionChanged(e, data)
 | 
			
		||||
{
 | 
			
		||||
    let filterForm = $('#form_video_filter');
 | 
			
		||||
    let filterForm_folderId = filterForm.find('#form_video_filter_folder_id');
 | 
			
		||||
    let filterForm_subId = filterForm.find('#form_video_filter_subscription_id');
 | 
			
		||||
 | 
			
		||||
    let node = data.instance.get_selected(true)[0];
 | 
			
		||||
 | 
			
		||||
    // Fill folder/sub fields
 | 
			
		||||
    if (node == null) {
 | 
			
		||||
        filterForm_folderId.val('');
 | 
			
		||||
        filterForm_subId.val('');
 | 
			
		||||
    }
 | 
			
		||||
    else if (node.type === 'folder') {
 | 
			
		||||
        let id = node.id.replace('folder', '');
 | 
			
		||||
        filterForm_folderId.val(id);
 | 
			
		||||
        filterForm_subId.val('');
 | 
			
		||||
    }
 | 
			
		||||
    else {
 | 
			
		||||
        let id = node.id.replace('sub', '');
 | 
			
		||||
        filterForm_folderId.val('');
 | 
			
		||||
        filterForm_subId.val(id);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    videos_Reload();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function videos_Reload()
 | 
			
		||||
{
 | 
			
		||||
    videos_Submit.call($('#form_video_filter'));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let videos_timeout = null;
 | 
			
		||||
 | 
			
		||||
function videos_ReloadWithTimer()
 | 
			
		||||
{
 | 
			
		||||
    clearTimeout(videos_timeout);
 | 
			
		||||
    videos_timeout = setTimeout(function()
 | 
			
		||||
    {
 | 
			
		||||
        videos_Submit.call($('#form_video_filter'));
 | 
			
		||||
        videos_timeout = null;
 | 
			
		||||
    }, 200);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function videos_Submit(e)
 | 
			
		||||
{
 | 
			
		||||
    let loadingDiv = $('#videos-loading');
 | 
			
		||||
    loadingDiv.fadeIn(300);
 | 
			
		||||
 | 
			
		||||
    let form = $(this);
 | 
			
		||||
    let url = form.attr('action');
 | 
			
		||||
 | 
			
		||||
    $.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>');
 | 
			
		||||
        })
 | 
			
		||||
        .always(function() {
 | 
			
		||||
            loadingDiv.fadeOut(100);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
    if (e != null)
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
///
 | 
			
		||||
/// Initialization
 | 
			
		||||
///
 | 
			
		||||
$(document).ready(function ()
 | 
			
		||||
{
 | 
			
		||||
    tree_Initialize();
 | 
			
		||||
 | 
			
		||||
    // Subscription toolbar
 | 
			
		||||
    $("#btn_create_sub").on("click", function () {
 | 
			
		||||
        let modal = new AjaxModal("{% url 'modal_create_subscription' %}");
 | 
			
		||||
        modal.setSubmitCallback(tree_Refresh);
 | 
			
		||||
        modal.loadAndShow();
 | 
			
		||||
    });
 | 
			
		||||
    $("#btn_create_folder").on("click", function () {
 | 
			
		||||
        let modal = new AjaxModal("{% url 'modal_create_folder' %}");
 | 
			
		||||
        modal.setSubmitCallback(tree_Refresh);
 | 
			
		||||
        modal.loadAndShow();
 | 
			
		||||
    });
 | 
			
		||||
    $("#btn_edit_node").on("click", treeNode_Edit);
 | 
			
		||||
    $("#btn_delete_node").on("click", treeNode_Delete);
 | 
			
		||||
 | 
			
		||||
    // Videos filters
 | 
			
		||||
    let filters_form = $("#form_video_filter");
 | 
			
		||||
    filters_form.submit(videos_Submit);
 | 
			
		||||
    filters_form.find('input[name=query]').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_downloaded]').on('change', videos_ReloadWithTimer);
 | 
			
		||||
    videos_Reload();
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										76
									
								
								app/YtManagerApp/templates/YtManagerApp/master_default.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								app/YtManagerApp/templates/YtManagerApp/master_default.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,76 @@
 | 
			
		||||
{% load static %}
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html>
 | 
			
		||||
    <head>
 | 
			
		||||
        <meta charset="utf-8">
 | 
			
		||||
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
 | 
			
		||||
        <title>{% block title %}YouTube Subscription Manager{% endblock %}</title>
 | 
			
		||||
 | 
			
		||||
        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
 | 
			
		||||
        <link rel="stylesheet" href="{% static 'YtManagerApp/import/typicons/typicons.min.css' %}" />
 | 
			
		||||
        <link rel="stylesheet" href="{% static 'YtManagerApp/css/style.css' %}">
 | 
			
		||||
        {% block stylesheets %}
 | 
			
		||||
        {% endblock %}
 | 
			
		||||
    </head>
 | 
			
		||||
    <body>
 | 
			
		||||
        <script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
 | 
			
		||||
        <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
 | 
			
		||||
        <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
 | 
			
		||||
        <script>
 | 
			
		||||
            {% include 'YtManagerApp/js/common.js' %}
 | 
			
		||||
        </script>
 | 
			
		||||
        {% block scripts %}
 | 
			
		||||
        {% endblock %}
 | 
			
		||||
        
 | 
			
		||||
        <nav class="navbar navbar-expand-lg navbar-light bg-light">
 | 
			
		||||
            <a class="navbar-brand" href="{% url 'home' %}">YouTube Manager</a>
 | 
			
		||||
            <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
 | 
			
		||||
                    aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
 | 
			
		||||
                <span class="navbar-toggler-icon"></span>
 | 
			
		||||
            </button>
 | 
			
		||||
 | 
			
		||||
            <div class="collapse navbar-collapse" id="navbarSupportedContent">
 | 
			
		||||
                <ul class="navbar-nav ml-auto">
 | 
			
		||||
                    {% if request.user.is_authenticated %}
 | 
			
		||||
                        <li class="nav-item dropdown">
 | 
			
		||||
                            <a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button"
 | 
			
		||||
                               data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
 | 
			
		||||
                                Welcome,
 | 
			
		||||
                                {% if request.user.first_name %}
 | 
			
		||||
                                    {{ request.user.first_name }}
 | 
			
		||||
                                {% else %}
 | 
			
		||||
                                    {{ request.user.username }}
 | 
			
		||||
                                {% endif %}
 | 
			
		||||
                            </a>
 | 
			
		||||
                            <div class="dropdown-menu dropdown-menu-right" aria-labelledby="userDropdown">
 | 
			
		||||
                                <a class="dropdown-item" href="{% url 'settings' %}">Settings</a>
 | 
			
		||||
                                <div class="dropdown-divider"></div>
 | 
			
		||||
                                <a class="dropdown-item" href="{% url 'logout' %}">Log out</a>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </li>
 | 
			
		||||
                    {% else %}
 | 
			
		||||
                        <li class="nav-item">
 | 
			
		||||
                            <a class="nav-link" href="{% url 'login' %}">Login</a>
 | 
			
		||||
                        </li>
 | 
			
		||||
                        <li class="nav-item">
 | 
			
		||||
                            <a class="nav-link" href="{% url 'register' %}">Register</a>
 | 
			
		||||
                        </li>
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
                </ul>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
        </nav>
 | 
			
		||||
 | 
			
		||||
        <div id="main_body" class="container-fluid">
 | 
			
		||||
            {% block body %}
 | 
			
		||||
            {% endblock %}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <footer id="main_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>
 | 
			
		||||
							
								
								
									
										12
									
								
								app/YtManagerApp/templates/YtManagerApp/settings.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								app/YtManagerApp/templates/YtManagerApp/settings.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
			
		||||
{% extends "YtManagerApp/master_default.html" %}
 | 
			
		||||
{% load crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
 | 
			
		||||
    <div class="container">
 | 
			
		||||
        <h1>Settings</h1>
 | 
			
		||||
        <p>If no value is set, the server's defaults will be used.</p>
 | 
			
		||||
        {% crispy form %}
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
{% endblock body %}
 | 
			
		||||
							
								
								
									
										27
									
								
								app/YtManagerApp/templates/registration/logged_out.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								app/YtManagerApp/templates/registration/logged_out.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
			
		||||
{% extends 'YtManagerApp/master_default.html' %}
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block scripts %}
 | 
			
		||||
    <script>
 | 
			
		||||
    
 | 
			
		||||
    window.setTimeout(function(){
 | 
			
		||||
        window.location.href = "/";
 | 
			
		||||
    }, 3000);
 | 
			
		||||
    
 | 
			
		||||
    </script>
 | 
			
		||||
    
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
 | 
			
		||||
    <div class="alert-card mx-auto">
 | 
			
		||||
 | 
			
		||||
        <div class="alert alert-info" role="alert">
 | 
			
		||||
            You have been logged out!
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										44
									
								
								app/YtManagerApp/templates/registration/login.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								app/YtManagerApp/templates/registration/login.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,44 @@
 | 
			
		||||
{% extends 'YtManagerApp/master_default.html' %}
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block stylesheets %}
 | 
			
		||||
    <link rel="stylesheet" href="{% static 'YtManagerApp/css/login.css' %}">
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
 | 
			
		||||
    <div class="login-card mx-auto">
 | 
			
		||||
 | 
			
		||||
        {% if next %}
 | 
			
		||||
            {% if user.is_authenticated %}
 | 
			
		||||
                <div class="alert alert-warning" role="alert">
 | 
			
		||||
                    Your account doesn't have access to this page. To proceed,
 | 
			
		||||
                    please login with an account that has access.
 | 
			
		||||
                </div>
 | 
			
		||||
            {% else %}
 | 
			
		||||
                <div class="alert alert-info" role="alert">
 | 
			
		||||
                    Please login or register to see this page.
 | 
			
		||||
                </div>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
        {% endif %}
 | 
			
		||||
 | 
			
		||||
        <h5>Login</h5>
 | 
			
		||||
 | 
			
		||||
        <form method="post" action="{% url 'login' %}">
 | 
			
		||||
            {% csrf_token %}
 | 
			
		||||
            <input type="hidden" name="next" value="{{ next }}"/>
 | 
			
		||||
 | 
			
		||||
            {{ form | crispy }}
 | 
			
		||||
 | 
			
		||||
            <div class="form-group">
 | 
			
		||||
                <input class="btn btn-primary" type="submit" value="login"/>
 | 
			
		||||
 | 
			
		||||
                <a class="ml-2" href="{% url 'password_reset' %}">Recover password</a>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
        </form>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -0,0 +1,26 @@
 | 
			
		||||
{% extends 'YtManagerApp/master_default.html' %}
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block scripts %}
 | 
			
		||||
    <script>
 | 
			
		||||
    
 | 
			
		||||
    window.setTimeout(function(){
 | 
			
		||||
        window.location.href = "/";
 | 
			
		||||
    }, 5000);
 | 
			
		||||
    
 | 
			
		||||
    </script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
 | 
			
		||||
    <div class="alert-card mx-auto">
 | 
			
		||||
 | 
			
		||||
        <div class="alert alert-info" role="alert">
 | 
			
		||||
            The password has been changed!
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -0,0 +1,33 @@
 | 
			
		||||
{% extends 'YtManagerApp/master_default.html' %}
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block stylesheets %}
 | 
			
		||||
    <link rel="stylesheet" href="{% static 'YtManagerApp/css/login.css' %}">
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
 | 
			
		||||
    {% if validlink %}
 | 
			
		||||
        <div class="login-card mx-auto">
 | 
			
		||||
            <h5>Confirm password reset</h5>
 | 
			
		||||
 | 
			
		||||
            <form method="post" action="">
 | 
			
		||||
                {% csrf_token %}
 | 
			
		||||
                {{ form | crispy }}
 | 
			
		||||
                <div class="form-group">
 | 
			
		||||
                    <input class="btn btn-primary" type="submit" value="change password"/>
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
            </form>
 | 
			
		||||
        </div>
 | 
			
		||||
    {% else %}
 | 
			
		||||
        <div class="alert-card mx-auto">
 | 
			
		||||
            <div class="alert alert-danger" role="alert">
 | 
			
		||||
                The password reset link was invalid, possibly because it has already been used. Please request a new password reset.
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -0,0 +1,28 @@
 | 
			
		||||
{% extends 'YtManagerApp/master_default.html' %}
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block scripts %}
 | 
			
		||||
    <script>
 | 
			
		||||
    
 | 
			
		||||
    window.setTimeout(function(){
 | 
			
		||||
        window.location.href = "/";
 | 
			
		||||
    }, 10000);
 | 
			
		||||
    
 | 
			
		||||
    </script>
 | 
			
		||||
    
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
 | 
			
		||||
    <div class="alert-card mx-auto">
 | 
			
		||||
 | 
			
		||||
        <div class="alert alert-info" role="alert">
 | 
			
		||||
            <p>We've emailed you instructions for resetting your password.</p>
 | 
			
		||||
            <p>If they haven't arrived in a few minutes, check your spam folder.</p>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -0,0 +1,2 @@
 | 
			
		||||
Someone asked for password reset for email {{ email }}. Follow the link below:
 | 
			
		||||
{{ protocol}}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
 | 
			
		||||
@@ -0,0 +1,45 @@
 | 
			
		||||
{% extends 'YtManagerApp/master_default.html' %}
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block stylesheets %}
 | 
			
		||||
    <link rel="stylesheet" href="{% static 'YtManagerApp/css/login.css' %}">
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
 | 
			
		||||
    <div class="login-card mx-auto">
 | 
			
		||||
 | 
			
		||||
        {% if next %}
 | 
			
		||||
            {% if user.is_authenticated %}
 | 
			
		||||
                <div class="alert alert-warning" role="alert">
 | 
			
		||||
                    Your account doesn't have access to this page. To proceed,
 | 
			
		||||
                    please login with an account that has access.
 | 
			
		||||
                </div>
 | 
			
		||||
            {% else %}
 | 
			
		||||
                <div class="alert alert-info" role="alert">
 | 
			
		||||
                    Please login or register to see this page.
 | 
			
		||||
                </div>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
        {% endif %}
 | 
			
		||||
 | 
			
		||||
        <h5>Password reset</h5>
 | 
			
		||||
 | 
			
		||||
        <form method="post" action="{% url 'password_reset' %}">
 | 
			
		||||
            {% csrf_token %}
 | 
			
		||||
            <input type="hidden" name="next" value="{{ next }}"/>
 | 
			
		||||
 | 
			
		||||
            {{ form | crispy }}
 | 
			
		||||
 | 
			
		||||
            <p>If there is any account associated with the given e-mail address,
 | 
			
		||||
                an e-mail will be sent containing the password reset link.</p>
 | 
			
		||||
 | 
			
		||||
            <div class="form-group">
 | 
			
		||||
                <input class="btn btn-primary" type="submit" value="reset"/>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
        </form>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										38
									
								
								app/YtManagerApp/templates/registration/register.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								app/YtManagerApp/templates/registration/register.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,38 @@
 | 
			
		||||
{% extends 'YtManagerApp/master_default.html' %}
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block stylesheets %}
 | 
			
		||||
    <link rel="stylesheet" href="{% static 'YtManagerApp/css/login.css' %}">
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
 | 
			
		||||
    <div class="register-card mx-auto">
 | 
			
		||||
 | 
			
		||||
        {% if next %}
 | 
			
		||||
            {% if user.is_authenticated %}
 | 
			
		||||
                <div class="alert alert-warning" role="alert">
 | 
			
		||||
                    Your account doesn't have access to this page. To proceed,
 | 
			
		||||
                    please login with an account that has access.
 | 
			
		||||
                </div>
 | 
			
		||||
            {% else %}
 | 
			
		||||
                <div class="alert alert-info" role="alert">
 | 
			
		||||
                    Please login or register to see this page.
 | 
			
		||||
                </div>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
        {% endif %}
 | 
			
		||||
 | 
			
		||||
        {% if is_first_user %}
 | 
			
		||||
            <div class="alert alert-info" role="alert">
 | 
			
		||||
                Since this is the first user to register, it will be the system administrator.
 | 
			
		||||
            </div>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
 | 
			
		||||
        <h5>Register</h5>
 | 
			
		||||
 | 
			
		||||
        {% crispy form %}
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										27
									
								
								app/YtManagerApp/templates/registration/register_done.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								app/YtManagerApp/templates/registration/register_done.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
			
		||||
{% extends 'YtManagerApp/master_default.html' %}
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load crispy_forms_tags %}
 | 
			
		||||
 | 
			
		||||
{% block scripts %}
 | 
			
		||||
    <script>
 | 
			
		||||
    
 | 
			
		||||
    window.setTimeout(function(){
 | 
			
		||||
        window.location.href = "/";
 | 
			
		||||
    }, 3000);
 | 
			
		||||
    
 | 
			
		||||
    </script>
 | 
			
		||||
    
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
 | 
			
		||||
    <div class="alert-card mx-auto">
 | 
			
		||||
 | 
			
		||||
        <div class="alert alert-info" role="alert">
 | 
			
		||||
            You have registered successfully!
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										0
									
								
								app/YtManagerApp/templatetags/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								app/YtManagerApp/templatetags/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										30
									
								
								app/YtManagerApp/templatetags/common.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								app/YtManagerApp/templatetags/common.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
from django import template
 | 
			
		||||
register = template.Library()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SetVarNode(template.Node):
 | 
			
		||||
 | 
			
		||||
    def __init__(self, var_name, var_value):
 | 
			
		||||
        self.var_name = var_name
 | 
			
		||||
        self.var_value = var_value
 | 
			
		||||
 | 
			
		||||
    def render(self, context):
 | 
			
		||||
        try:
 | 
			
		||||
            value = template.Variable(self.var_value).resolve(context)
 | 
			
		||||
        except template.VariableDoesNotExist:
 | 
			
		||||
            value = ""
 | 
			
		||||
        context[self.var_name] = value
 | 
			
		||||
 | 
			
		||||
        return u""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@register.tag(name='set')
 | 
			
		||||
def set_var(parser, token):
 | 
			
		||||
    """
 | 
			
		||||
    {% set some_var = '123' %}
 | 
			
		||||
    """
 | 
			
		||||
    parts = token.split_contents()
 | 
			
		||||
    if len(parts) < 4:
 | 
			
		||||
        raise template.TemplateSyntaxError("'set' tag must be of the form: {% set <var_name> = <var_value> %}")
 | 
			
		||||
 | 
			
		||||
    return SetVarNode(parts[1], parts[3])
 | 
			
		||||
							
								
								
									
										61
									
								
								app/YtManagerApp/templatetags/ratings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								app/YtManagerApp/templatetags/ratings.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,61 @@
 | 
			
		||||
from django import template
 | 
			
		||||
register = template.Library()
 | 
			
		||||
 | 
			
		||||
FULL_STAR_CLASS = "typcn-star-full-outline"
 | 
			
		||||
HALF_STAR_CLASS = "typcn-star-half-outline"
 | 
			
		||||
EMPTY_STAR_CLASS = "typcn-star-outline"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class StarRatingNode(template.Node):
 | 
			
		||||
 | 
			
		||||
    def __init__(self, rating_percent, max_stars="5"):
 | 
			
		||||
        self.rating = rating_percent
 | 
			
		||||
        self.max_stars = max_stars
 | 
			
		||||
 | 
			
		||||
    def render(self, context):
 | 
			
		||||
        try:
 | 
			
		||||
            rating = template.Variable(self.rating).resolve(context)
 | 
			
		||||
        except template.VariableDoesNotExist:
 | 
			
		||||
            rating = 0
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            max_stars = template.Variable(self.max_stars).resolve(context)
 | 
			
		||||
        except template.VariableDoesNotExist:
 | 
			
		||||
            max_stars = 0
 | 
			
		||||
 | 
			
		||||
        total_halves = (max_stars - 1) * rating * 2
 | 
			
		||||
 | 
			
		||||
        html = [
 | 
			
		||||
            f'<div class="star-rating" title="{ 1 + (total_halves / 2):.2f} stars">'
 | 
			
		||||
            f'<span class="typcn {FULL_STAR_CLASS}"></span>'
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        for i in range(max_stars - 1):
 | 
			
		||||
            if total_halves >= 2 * i + 2:
 | 
			
		||||
                cls = FULL_STAR_CLASS
 | 
			
		||||
            elif total_halves >= 2 * i + 1:
 | 
			
		||||
                cls = HALF_STAR_CLASS
 | 
			
		||||
            else:
 | 
			
		||||
                cls = EMPTY_STAR_CLASS
 | 
			
		||||
 | 
			
		||||
            html.append(f'<span class="typcn {cls}"></span>')
 | 
			
		||||
 | 
			
		||||
        html.append("</div>")
 | 
			
		||||
 | 
			
		||||
        return u"".join(html)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@register.tag(name='starrating')
 | 
			
		||||
def star_rating_tag(parser, token):
 | 
			
		||||
    """
 | 
			
		||||
    {% rating percent [max_stars=5]%}
 | 
			
		||||
    """
 | 
			
		||||
    parts = token.split_contents()
 | 
			
		||||
    if len(parts) <= 1:
 | 
			
		||||
        raise template.TemplateSyntaxError("'set' tag must be of the form: {% rating <value_percent> [<max_stars>=5] %}")
 | 
			
		||||
 | 
			
		||||
    if len(parts) <= 2:
 | 
			
		||||
        return StarRatingNode(parts[1])
 | 
			
		||||
 | 
			
		||||
    return StarRatingNode(parts[1], parts[2])
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										3
									
								
								app/YtManagerApp/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								app/YtManagerApp/tests.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
 | 
			
		||||
# Create your tests here.
 | 
			
		||||
							
								
								
									
										60
									
								
								app/YtManagerApp/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								app/YtManagerApp/urls.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,60 @@
 | 
			
		||||
"""YtManager URL Configuration
 | 
			
		||||
 | 
			
		||||
The `urlpatterns` list routes URLs to views. For more information please see:
 | 
			
		||||
    https://docs.djangoproject.com/en/1.11/topics/http/urls/
 | 
			
		||||
Examples:
 | 
			
		||||
Function views
 | 
			
		||||
    1. Add an import:  from my_app import views
 | 
			
		||||
    2. Add a URL to urlpatterns:  url(r'^$', views.home, name='home')
 | 
			
		||||
Class-based views
 | 
			
		||||
    1. Add an import:  from other_app.views import Home
 | 
			
		||||
    2. Add a URL to urlpatterns:  url(r'^$', Home.as_view(), name='home')
 | 
			
		||||
Including another URLconf
 | 
			
		||||
    1. Import the include() function: from django.conf.urls import url, include
 | 
			
		||||
    2. Add a URL to urlpatterns:  url(r'^blog/', include('blog.urls'))
 | 
			
		||||
"""
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.conf.urls import include
 | 
			
		||||
from django.conf.urls.static import static
 | 
			
		||||
from django.urls import path
 | 
			
		||||
 | 
			
		||||
from .views.actions import SyncNowView, DeleteVideoFilesView, DownloadVideoFilesView, MarkVideoWatchedView, \
 | 
			
		||||
    MarkVideoUnwatchedView
 | 
			
		||||
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.settings import SettingsView
 | 
			
		||||
 | 
			
		||||
urlpatterns = [
 | 
			
		||||
    # Authentication URLs
 | 
			
		||||
    path('login/', ExtendedLoginView.as_view(), name='login'),
 | 
			
		||||
    path('register/', RegisterView.as_view(), name='register'),
 | 
			
		||||
    path('register_done/', RegisterDoneView.as_view(), name='register_done'),
 | 
			
		||||
    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'),
 | 
			
		||||
 | 
			
		||||
    # Modals
 | 
			
		||||
    path('modal/create_folder/', CreateFolderModal.as_view(), name='modal_create_folder'),
 | 
			
		||||
    path('modal/create_folder/<int:parent_id>/', CreateFolderModal.as_view(), name='modal_create_folder'),
 | 
			
		||||
    path('modal/update_folder/<int:pk>/', UpdateFolderModal.as_view(), name='modal_update_folder'),
 | 
			
		||||
    path('modal/delete_folder/<int:pk>/', DeleteFolderModal.as_view(), name='modal_delete_folder'),
 | 
			
		||||
 | 
			
		||||
    path('modal/create_subscription/', CreateSubscriptionModal.as_view(), name='modal_create_subscription'),
 | 
			
		||||
    path('modal/create_subscription/<int:parent_folder_id>/', CreateSubscriptionModal.as_view(), name='modal_create_subscription'),
 | 
			
		||||
    path('modal/update_subscription/<int:pk>/', UpdateSubscriptionModal.as_view(), name='modal_update_subscription'),
 | 
			
		||||
    path('modal/delete_subscription/<int:pk>/', DeleteSubscriptionModal.as_view(), name='modal_delete_subscription'),
 | 
			
		||||
 | 
			
		||||
    # Pages
 | 
			
		||||
    path('', index, name='home'),
 | 
			
		||||
    path('settings/', SettingsView.as_view(), name='settings'),
 | 
			
		||||
 | 
			
		||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
 | 
			
		||||
							
								
								
									
										0
									
								
								app/YtManagerApp/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								app/YtManagerApp/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										92
									
								
								app/YtManagerApp/utils/extended_interpolation_with_env.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								app/YtManagerApp/utils/extended_interpolation_with_env.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,92 @@
 | 
			
		||||
import os
 | 
			
		||||
import os.path
 | 
			
		||||
import re
 | 
			
		||||
from configparser import Interpolation, NoSectionError, NoOptionError, InterpolationMissingOptionError, \
 | 
			
		||||
    InterpolationDepthError, InterpolationSyntaxError
 | 
			
		||||
 | 
			
		||||
MAX_INTERPOLATION_DEPTH = 10
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ExtendedInterpolatorWithEnv(Interpolation):
 | 
			
		||||
    """Advanced variant of interpolation, supports the syntax used by
 | 
			
		||||
    `zc.buildout'. Enables interpolation between sections.
 | 
			
		||||
 | 
			
		||||
    This modified version also allows specifying environment variables
 | 
			
		||||
    using ${env:...}, and allows adding additional options using 'set_additional_options'. """
 | 
			
		||||
 | 
			
		||||
    _KEYCRE = re.compile(r"\$\{([^}]+)\}")
 | 
			
		||||
 | 
			
		||||
    def before_get(self, parser, section, option, value, defaults):
 | 
			
		||||
        L = []
 | 
			
		||||
        self._interpolate_some(parser, option, L, value, section, defaults, 1)
 | 
			
		||||
        return ''.join(L)
 | 
			
		||||
 | 
			
		||||
    def before_set(self, parser, section, option, value):
 | 
			
		||||
        tmp_value = value.replace('$$', '')  # escaped dollar signs
 | 
			
		||||
        tmp_value = self._KEYCRE.sub('', tmp_value)  # valid syntax
 | 
			
		||||
        if '$' in tmp_value:
 | 
			
		||||
            raise ValueError("invalid interpolation syntax in %r at "
 | 
			
		||||
                             "position %d" % (value, tmp_value.find('$')))
 | 
			
		||||
        return value
 | 
			
		||||
 | 
			
		||||
    def _resolve_option(self, option, defaults):
 | 
			
		||||
        return defaults[option]
 | 
			
		||||
 | 
			
		||||
    def _resolve_section_option(self, section, option, parser):
 | 
			
		||||
        if section == 'env':
 | 
			
		||||
            return os.getenv(option, '')
 | 
			
		||||
        return parser.get(section, option, raw=True)
 | 
			
		||||
 | 
			
		||||
    def _interpolate_some(self, parser, option, accum, rest, section, map,
 | 
			
		||||
                          depth):
 | 
			
		||||
        rawval = parser.get(section, option, raw=True, fallback=rest)
 | 
			
		||||
        if depth > MAX_INTERPOLATION_DEPTH:
 | 
			
		||||
            raise InterpolationDepthError(option, section, rawval)
 | 
			
		||||
        while rest:
 | 
			
		||||
            p = rest.find("$")
 | 
			
		||||
            if p < 0:
 | 
			
		||||
                accum.append(rest)
 | 
			
		||||
                return
 | 
			
		||||
            if p > 0:
 | 
			
		||||
                accum.append(rest[:p])
 | 
			
		||||
                rest = rest[p:]
 | 
			
		||||
            # p is no longer used
 | 
			
		||||
            c = rest[1:2]
 | 
			
		||||
            if c == "$":
 | 
			
		||||
                accum.append("$")
 | 
			
		||||
                rest = rest[2:]
 | 
			
		||||
            elif c == "{":
 | 
			
		||||
                m = self._KEYCRE.match(rest)
 | 
			
		||||
                if m is None:
 | 
			
		||||
                    raise InterpolationSyntaxError(option, section,
 | 
			
		||||
                                                   "bad interpolation variable reference %r" % rest)
 | 
			
		||||
                path = m.group(1).split(':')
 | 
			
		||||
                rest = rest[m.end():]
 | 
			
		||||
                sect = section
 | 
			
		||||
                opt = option
 | 
			
		||||
                try:
 | 
			
		||||
                    if len(path) == 1:
 | 
			
		||||
                        opt = parser.optionxform(path[0])
 | 
			
		||||
                        v = self._resolve_option(opt, map)
 | 
			
		||||
                    elif len(path) == 2:
 | 
			
		||||
                        sect = path[0]
 | 
			
		||||
                        opt = parser.optionxform(path[1])
 | 
			
		||||
                        v = self._resolve_section_option(sect, opt, parser)
 | 
			
		||||
                    else:
 | 
			
		||||
                        raise InterpolationSyntaxError(
 | 
			
		||||
                            option, section,
 | 
			
		||||
                            "More than one ':' found: %r" % (rest,))
 | 
			
		||||
                except (KeyError, NoSectionError, NoOptionError):
 | 
			
		||||
                    raise InterpolationMissingOptionError(
 | 
			
		||||
                        option, section, rawval, ":".join(path)) from None
 | 
			
		||||
                if "$" in v:
 | 
			
		||||
                    self._interpolate_some(parser, opt, accum, v, sect,
 | 
			
		||||
                                           dict(parser.items(sect, raw=True)),
 | 
			
		||||
                                           depth + 1)
 | 
			
		||||
                else:
 | 
			
		||||
                    accum.append(v)
 | 
			
		||||
            else:
 | 
			
		||||
                raise InterpolationSyntaxError(
 | 
			
		||||
                    option, section,
 | 
			
		||||
                    "'$' must be followed by '$' or '{', "
 | 
			
		||||
                    "found: %r" % (rest,))
 | 
			
		||||
							
								
								
									
										48
									
								
								app/YtManagerApp/utils/youtube.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								app/YtManagerApp/utils/youtube.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,48 @@
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from external.pytaw.pytaw.youtube import YouTube, Channel, Playlist, PlaylistItem, Thumbnail, InvalidURL, Resource, Video
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class YoutubeAPI(YouTube):
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def build_public() -> 'YoutubeAPI':
 | 
			
		||||
        return YoutubeAPI(key=settings.YOUTUBE_API_KEY)
 | 
			
		||||
 | 
			
		||||
    # @staticmethod
 | 
			
		||||
    # def build_oauth() -> 'YoutubeAPI':
 | 
			
		||||
    #     flow =
 | 
			
		||||
    #     credentials =
 | 
			
		||||
    #     service = build(API_SERVICE_NAME, API_VERSION, credentials)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def default_thumbnail(resource: Resource) -> Optional[Thumbnail]:
 | 
			
		||||
    """
 | 
			
		||||
    Gets the default thumbnail for a resource.
 | 
			
		||||
    Searches in the list of thumbnails for one with the label 'default', or takes the first one.
 | 
			
		||||
    :param resource:
 | 
			
		||||
    :return:
 | 
			
		||||
    """
 | 
			
		||||
    thumbs = getattr(resource, 'thumbnails', None)
 | 
			
		||||
 | 
			
		||||
    if thumbs is None or len(thumbs) <= 0:
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    return next(
 | 
			
		||||
        (i for i in thumbs if i.id == 'default'),
 | 
			
		||||
        thumbs[0]
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def best_thumbnail(resource: Resource) -> Optional[Thumbnail]:
 | 
			
		||||
    """
 | 
			
		||||
    Gets the best thumbnail available for a resource.
 | 
			
		||||
    :param resource:
 | 
			
		||||
    :return:
 | 
			
		||||
    """
 | 
			
		||||
    thumbs = getattr(resource, 'thumbnails', None)
 | 
			
		||||
 | 
			
		||||
    if thumbs is None or len(thumbs) <= 0:
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    return max(thumbs, key=lambda t: t.width * t.height)
 | 
			
		||||
							
								
								
									
										0
									
								
								app/YtManagerApp/views/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								app/YtManagerApp/views/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										51
									
								
								app/YtManagerApp/views/actions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								app/YtManagerApp/views/actions.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,51 @@
 | 
			
		||||
from django.contrib.auth.mixins import LoginRequiredMixin
 | 
			
		||||
from django.http import JsonResponse
 | 
			
		||||
from django.views.generic import View
 | 
			
		||||
 | 
			
		||||
from YtManagerApp.management.jobs.synchronize import schedule_synchronize_now
 | 
			
		||||
from YtManagerApp.models import Video
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SyncNowView(LoginRequiredMixin, View):
 | 
			
		||||
    def post(self, *args, **kwargs):
 | 
			
		||||
        schedule_synchronize_now()
 | 
			
		||||
        return JsonResponse({
 | 
			
		||||
            'success': True
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DeleteVideoFilesView(LoginRequiredMixin, View):
 | 
			
		||||
    def post(self, *args, **kwargs):
 | 
			
		||||
        video = Video.objects.get(id=kwargs['pk'])
 | 
			
		||||
        video.delete_files()
 | 
			
		||||
        return JsonResponse({
 | 
			
		||||
            'success': True
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DownloadVideoFilesView(LoginRequiredMixin, View):
 | 
			
		||||
    def post(self, *args, **kwargs):
 | 
			
		||||
        video = Video.objects.get(id=kwargs['pk'])
 | 
			
		||||
        video.download()
 | 
			
		||||
        return JsonResponse({
 | 
			
		||||
            'success': True
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MarkVideoWatchedView(LoginRequiredMixin, View):
 | 
			
		||||
    def post(self, *args, **kwargs):
 | 
			
		||||
        video = Video.objects.get(id=kwargs['pk'])
 | 
			
		||||
        video.mark_watched()
 | 
			
		||||
        return JsonResponse({
 | 
			
		||||
            'success': True
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MarkVideoUnwatchedView(LoginRequiredMixin, View):
 | 
			
		||||
    def post(self, *args, **kwargs):
 | 
			
		||||
        video = Video.objects.get(id=kwargs['pk'])
 | 
			
		||||
        video.mark_unwatched()
 | 
			
		||||
        video.save()
 | 
			
		||||
        return JsonResponse({
 | 
			
		||||
            'success': True
 | 
			
		||||
        })
 | 
			
		||||
							
								
								
									
										83
									
								
								app/YtManagerApp/views/auth.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								app/YtManagerApp/views/auth.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,83 @@
 | 
			
		||||
from crispy_forms.helper import FormHelper
 | 
			
		||||
from crispy_forms.layout import Submit
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.contrib.auth import login, authenticate
 | 
			
		||||
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
 | 
			
		||||
from django.contrib.auth.mixins import LoginRequiredMixin
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.contrib.auth.views import LoginView
 | 
			
		||||
from django.urls import reverse_lazy
 | 
			
		||||
from django.views.generic import FormView, TemplateView
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ExtendedAuthenticationForm(AuthenticationForm):
 | 
			
		||||
    remember_me = forms.BooleanField(label='Remember me', required=False, initial=False)
 | 
			
		||||
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        remember_me = self.cleaned_data.get('remember_me')
 | 
			
		||||
        if remember_me:
 | 
			
		||||
            expiry = 3600 * 24 * 30
 | 
			
		||||
        else:
 | 
			
		||||
            expiry = 0
 | 
			
		||||
        self.request.session.set_expiry(expiry)
 | 
			
		||||
 | 
			
		||||
        return super().clean()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ExtendedLoginView(LoginView):
 | 
			
		||||
    form_class = ExtendedAuthenticationForm
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ExtendedUserCreationForm(UserCreationForm):
 | 
			
		||||
    email = forms.EmailField(required=False,
 | 
			
		||||
                             label='E-mail address',
 | 
			
		||||
                             help_text='The e-mail address is optional, but it is the only way to recover a lost '
 | 
			
		||||
                                       'password.')
 | 
			
		||||
    first_name = forms.CharField(max_length=30, required=False,
 | 
			
		||||
                                 label='First name')
 | 
			
		||||
    last_name = forms.CharField(max_length=150, required=False,
 | 
			
		||||
                                label='Last name')
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        self.helper = FormHelper()
 | 
			
		||||
        self.helper.label_class = 'col-3'
 | 
			
		||||
        self.helper.field_class = 'col-9'
 | 
			
		||||
        self.helper.form_class = 'form-horizontal'
 | 
			
		||||
        self.helper.form_method = 'post'
 | 
			
		||||
        self.helper.form_action = reverse_lazy('register')
 | 
			
		||||
        self.helper.add_input(Submit('submit', 'register'))
 | 
			
		||||
 | 
			
		||||
    class Meta(UserCreationForm.Meta):
 | 
			
		||||
        fields = ['username', 'email', 'first_name', 'last_name']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RegisterView(FormView):
 | 
			
		||||
    template_name = 'registration/register.html'
 | 
			
		||||
    form_class = ExtendedUserCreationForm
 | 
			
		||||
    success_url = reverse_lazy('register_done')
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        is_first_user = (User.objects.count() == 0)
 | 
			
		||||
 | 
			
		||||
        user = form.save()
 | 
			
		||||
        if is_first_user:
 | 
			
		||||
            user.is_staff = True
 | 
			
		||||
            user.is_superuser = True
 | 
			
		||||
            user.save()
 | 
			
		||||
 | 
			
		||||
        username = form.cleaned_data.get('username')
 | 
			
		||||
        password = form.cleaned_data.get('password1')
 | 
			
		||||
        user = authenticate(username=username, password=password)
 | 
			
		||||
        login(self.request, user)
 | 
			
		||||
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
        context['is_first_user'] = (User.objects.count() == 0)
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RegisterDoneView(LoginRequiredMixin, TemplateView):
 | 
			
		||||
    template_name = 'registration/register_done.html'
 | 
			
		||||
							
								
								
									
										0
									
								
								app/YtManagerApp/views/controls/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								app/YtManagerApp/views/controls/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										53
									
								
								app/YtManagerApp/views/controls/modal.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								app/YtManagerApp/views/controls/modal.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,53 @@
 | 
			
		||||
from django.views.generic.base import ContextMixin
 | 
			
		||||
from django.http import JsonResponse
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ModalMixin(ContextMixin):
 | 
			
		||||
    template_name = 'YtManagerApp/controls/modal.html'
 | 
			
		||||
    success_url = '/'
 | 
			
		||||
 | 
			
		||||
    def __init__(self, modal_id='dialog', title='', fade=True, centered=True, small=False, large=False, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        self.id = modal_id
 | 
			
		||||
        self.title = title
 | 
			
		||||
        self.fade = fade
 | 
			
		||||
        self.centered = centered
 | 
			
		||||
        self.small = small
 | 
			
		||||
        self.large = large
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        data = super().get_context_data(**kwargs)
 | 
			
		||||
        data['modal_id'] = self.id
 | 
			
		||||
 | 
			
		||||
        data['modal_classes'] = ''
 | 
			
		||||
        if self.fade:
 | 
			
		||||
            data['modal_classes'] += 'fade '
 | 
			
		||||
 | 
			
		||||
        data['modal_dialog_classes'] = ''
 | 
			
		||||
        if self.centered:
 | 
			
		||||
            data['modal_dialog_classes'] += 'modal-dialog-centered '
 | 
			
		||||
        if self.small:
 | 
			
		||||
            data['modal_dialog_classes'] += 'modal-sm '
 | 
			
		||||
        elif self.large:
 | 
			
		||||
            data['modal_dialog_classes'] += 'modal-lg '
 | 
			
		||||
 | 
			
		||||
        data['modal_title'] = self.title
 | 
			
		||||
 | 
			
		||||
        return data
 | 
			
		||||
 | 
			
		||||
    def modal_response(self, form, success=True, error_msg=None):
 | 
			
		||||
        result = {'success': success}
 | 
			
		||||
        if not success:
 | 
			
		||||
            result['errors'] = form.errors.get_json_data(escape_html=True)
 | 
			
		||||
        if error_msg is not None:
 | 
			
		||||
            result['errors']['__all__'] = [{'message': error_msg}]
 | 
			
		||||
 | 
			
		||||
        return JsonResponse(result)
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        super().form_valid(form)
 | 
			
		||||
        return self.modal_response(form, success=True)
 | 
			
		||||
 | 
			
		||||
    def form_invalid(self, form):
 | 
			
		||||
        super().form_invalid(form)
 | 
			
		||||
        return self.modal_response(form, success=False)
 | 
			
		||||
							
								
								
									
										348
									
								
								app/YtManagerApp/views/index.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										348
									
								
								app/YtManagerApp/views/index.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,348 @@
 | 
			
		||||
from crispy_forms.helper import FormHelper
 | 
			
		||||
from crispy_forms.layout import Layout, Field, HTML
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.contrib.auth.decorators import login_required
 | 
			
		||||
from django.contrib.auth.mixins import LoginRequiredMixin
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.http import HttpRequest, HttpResponseBadRequest, JsonResponse
 | 
			
		||||
from django.shortcuts import render
 | 
			
		||||
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, VIDEO_ORDER_CHOICES, VIDEO_ORDER_MAPPING
 | 
			
		||||
from YtManagerApp.utils import youtube
 | 
			
		||||
from YtManagerApp.views.controls.modal import ModalMixin
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class VideoFilterForm(forms.Form):
 | 
			
		||||
    CHOICES_SHOW_WATCHED = (
 | 
			
		||||
        ('y', 'Watched'),
 | 
			
		||||
        ('n', 'Not watched'),
 | 
			
		||||
        ('all', '(All)')
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    CHOICES_SHOW_DOWNLOADED = (
 | 
			
		||||
        ('y', 'Downloaded'),
 | 
			
		||||
        ('n', 'Not downloaded'),
 | 
			
		||||
        ('all', '(All)')
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    MAPPING_SHOW = {
 | 
			
		||||
        'y': True,
 | 
			
		||||
        'n': False,
 | 
			
		||||
        'all': None
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    query = forms.CharField(label='', required=False)
 | 
			
		||||
    sort = forms.ChoiceField(label='Sort:', choices=VIDEO_ORDER_CHOICES, initial='newest')
 | 
			
		||||
    show_watched = forms.ChoiceField(label='Show only: ', choices=CHOICES_SHOW_WATCHED, initial='all')
 | 
			
		||||
    show_downloaded = forms.ChoiceField(label='', choices=CHOICES_SHOW_DOWNLOADED, initial='all')
 | 
			
		||||
    subscription_id = forms.IntegerField(
 | 
			
		||||
        required=False,
 | 
			
		||||
        widget=forms.HiddenInput()
 | 
			
		||||
    )
 | 
			
		||||
    folder_id = forms.IntegerField(
 | 
			
		||||
        required=False,
 | 
			
		||||
        widget=forms.HiddenInput()
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __init__(self, data=None):
 | 
			
		||||
        super().__init__(data, auto_id='form_video_filter_%s')
 | 
			
		||||
        self.helper = FormHelper()
 | 
			
		||||
        self.helper.form_id = 'form_video_filter'
 | 
			
		||||
        self.helper.form_class = 'form-inline'
 | 
			
		||||
        self.helper.form_method = 'POST'
 | 
			
		||||
        self.helper.form_action = 'ajax_get_videos'
 | 
			
		||||
        self.helper.field_class = 'mr-1'
 | 
			
		||||
        self.helper.label_class = 'ml-2 mr-1 no-asterisk'
 | 
			
		||||
 | 
			
		||||
        self.helper.layout = Layout(
 | 
			
		||||
            Field('query', placeholder='Search'),
 | 
			
		||||
            'sort',
 | 
			
		||||
            'show_watched',
 | 
			
		||||
            'show_downloaded',
 | 
			
		||||
            'subscription_id',
 | 
			
		||||
            'folder_id'
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def clean_sort(self):
 | 
			
		||||
        data = self.cleaned_data['sort']
 | 
			
		||||
        return VIDEO_ORDER_MAPPING[data]
 | 
			
		||||
 | 
			
		||||
    def clean_show_downloaded(self):
 | 
			
		||||
        data = self.cleaned_data['show_downloaded']
 | 
			
		||||
        return VideoFilterForm.MAPPING_SHOW[data]
 | 
			
		||||
 | 
			
		||||
    def clean_show_watched(self):
 | 
			
		||||
        data = self.cleaned_data['show_watched']
 | 
			
		||||
        return VideoFilterForm.MAPPING_SHOW[data]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __tree_folder_id(fd_id):
 | 
			
		||||
    if fd_id is None:
 | 
			
		||||
        return '#'
 | 
			
		||||
    return 'folder' + str(fd_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __tree_sub_id(sub_id):
 | 
			
		||||
    if sub_id is None:
 | 
			
		||||
        return '#'
 | 
			
		||||
    return 'sub' + str(sub_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def index(request: HttpRequest):
 | 
			
		||||
    if request.user.is_authenticated:
 | 
			
		||||
        context = {
 | 
			
		||||
            'filter_form': VideoFilterForm()
 | 
			
		||||
        }
 | 
			
		||||
        return render(request, 'YtManagerApp/index.html', context)
 | 
			
		||||
    else:
 | 
			
		||||
        return render(request, 'YtManagerApp/index_unauthenticated.html')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@login_required
 | 
			
		||||
def ajax_get_tree(request: HttpRequest):
 | 
			
		||||
 | 
			
		||||
    def visit(node):
 | 
			
		||||
        if isinstance(node, SubscriptionFolder):
 | 
			
		||||
            return {
 | 
			
		||||
                "id": __tree_folder_id(node.id),
 | 
			
		||||
                "text": node.name,
 | 
			
		||||
                "type": "folder",
 | 
			
		||||
                "state": {"opened": True},
 | 
			
		||||
                "parent": __tree_folder_id(node.parent_id)
 | 
			
		||||
            }
 | 
			
		||||
        elif isinstance(node, Subscription):
 | 
			
		||||
            return {
 | 
			
		||||
                "id": __tree_sub_id(node.id),
 | 
			
		||||
                "type": "sub",
 | 
			
		||||
                "text": node.name,
 | 
			
		||||
                "icon": node.icon_default,
 | 
			
		||||
                "parent": __tree_folder_id(node.parent_folder_id)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
    result = SubscriptionFolder.traverse(None, request.user, visit)
 | 
			
		||||
    return JsonResponse(result, safe=False)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@login_required
 | 
			
		||||
def ajax_get_videos(request: HttpRequest):
 | 
			
		||||
    if request.method == 'POST':
 | 
			
		||||
        form = VideoFilterForm(request.POST)
 | 
			
		||||
        if form.is_valid():
 | 
			
		||||
            videos = get_videos(
 | 
			
		||||
                user=request.user,
 | 
			
		||||
                sort_order=form.cleaned_data['sort'],
 | 
			
		||||
                query=form.cleaned_data['query'],
 | 
			
		||||
                subscription_id=form.cleaned_data['subscription_id'],
 | 
			
		||||
                folder_id=form.cleaned_data['folder_id'],
 | 
			
		||||
                only_watched=form.cleaned_data['show_watched'],
 | 
			
		||||
                only_downloaded=form.cleaned_data['show_downloaded']
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            context = {
 | 
			
		||||
                'videos': videos
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return render(request, 'YtManagerApp/index_videos.html', context)
 | 
			
		||||
 | 
			
		||||
    return HttpResponseBadRequest()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SubscriptionFolderForm(forms.ModelForm):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = SubscriptionFolder
 | 
			
		||||
        fields = ['name', 'parent']
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        self.helper = FormHelper()
 | 
			
		||||
        self.helper.form_tag = False
 | 
			
		||||
 | 
			
		||||
    def clean_name(self):
 | 
			
		||||
        name = self.cleaned_data['name']
 | 
			
		||||
        return name.strip()
 | 
			
		||||
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        cleaned_data = super().clean()
 | 
			
		||||
        name = cleaned_data.get('name')
 | 
			
		||||
        parent = cleaned_data.get('parent')
 | 
			
		||||
 | 
			
		||||
        # Check name is unique in parent folder
 | 
			
		||||
        args_id = []
 | 
			
		||||
        if self.instance is not None:
 | 
			
		||||
            args_id.append(~Q(id=self.instance.id))
 | 
			
		||||
 | 
			
		||||
        if SubscriptionFolder.objects.filter(parent=parent, name__iexact=name, *args_id).count() > 0:
 | 
			
		||||
            raise forms.ValidationError(
 | 
			
		||||
                'A folder with the same name already exists in the given parent directory!', code='already_exists')
 | 
			
		||||
 | 
			
		||||
        # Check for cycles
 | 
			
		||||
        if self.instance is not None:
 | 
			
		||||
            self.__test_cycles(parent)
 | 
			
		||||
 | 
			
		||||
    def __test_cycles(self, new_parent):
 | 
			
		||||
        visited = [self.instance.id]
 | 
			
		||||
        current = new_parent
 | 
			
		||||
        while current is not None:
 | 
			
		||||
            if current.id in visited:
 | 
			
		||||
                raise forms.ValidationError('Selected parent would create a parenting cycle!', code='parenting_cycle')
 | 
			
		||||
            visited.append(current.id)
 | 
			
		||||
            current = current.parent
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CreateFolderModal(LoginRequiredMixin, ModalMixin, CreateView):
 | 
			
		||||
    template_name = 'YtManagerApp/controls/folder_create_modal.html'
 | 
			
		||||
    form_class = SubscriptionFolderForm
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        form.instance.user = self.request.user
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UpdateFolderModal(LoginRequiredMixin, ModalMixin, UpdateView):
 | 
			
		||||
    template_name = 'YtManagerApp/controls/folder_update_modal.html'
 | 
			
		||||
    model = SubscriptionFolder
 | 
			
		||||
    form_class = SubscriptionFolderForm
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DeleteFolderForm(forms.Form):
 | 
			
		||||
    keep_subscriptions = forms.BooleanField(required=False, initial=False, label="Keep subscriptions")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DeleteFolderModal(LoginRequiredMixin, ModalMixin, FormMixin, DeleteView):
 | 
			
		||||
    template_name = 'YtManagerApp/controls/folder_delete_modal.html'
 | 
			
		||||
    model = SubscriptionFolder
 | 
			
		||||
    form_class = DeleteFolderForm
 | 
			
		||||
 | 
			
		||||
    def delete(self, request, *args, **kwargs):
 | 
			
		||||
        self.object = self.get_object()
 | 
			
		||||
        form = self.get_form()
 | 
			
		||||
        if form.is_valid():
 | 
			
		||||
            return self.form_valid(form)
 | 
			
		||||
        else:
 | 
			
		||||
            return self.form_invalid(form)
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        self.object.delete_folder(keep_subscriptions=form.cleaned_data['keep_subscriptions'])
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CreateSubscriptionForm(forms.ModelForm):
 | 
			
		||||
    playlist_url = forms.URLField(label='Playlist/Channel URL')
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Subscription
 | 
			
		||||
        fields = ['parent_folder', 'auto_download',
 | 
			
		||||
                  'download_limit', 'download_order', 'delete_after_watched']
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        self.yt_api = youtube.YoutubeAPI.build_public()
 | 
			
		||||
        self.helper = FormHelper()
 | 
			
		||||
        self.helper.form_tag = False
 | 
			
		||||
        self.helper.layout = Layout(
 | 
			
		||||
            'playlist_url',
 | 
			
		||||
            'parent_folder',
 | 
			
		||||
            HTML('<hr>'),
 | 
			
		||||
            HTML('<h5>Download configuration overloads</h5>'),
 | 
			
		||||
            'auto_download',
 | 
			
		||||
            'download_limit',
 | 
			
		||||
            'download_order',
 | 
			
		||||
            'delete_after_watched'
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def clean_playlist_url(self):
 | 
			
		||||
        playlist_url: str = self.cleaned_data['playlist_url']
 | 
			
		||||
        try:
 | 
			
		||||
            parsed_url = self.yt_api.parse_url(playlist_url)
 | 
			
		||||
        except youtube.InvalidURL as e:
 | 
			
		||||
            raise forms.ValidationError(str(e))
 | 
			
		||||
 | 
			
		||||
        is_playlist = 'playlist' in parsed_url
 | 
			
		||||
        is_channel = parsed_url['type'] in ('channel', 'user', 'channel_custom')
 | 
			
		||||
 | 
			
		||||
        if not is_channel and not is_playlist:
 | 
			
		||||
            raise forms.ValidationError('The given URL must link to a channel or a playlist!')
 | 
			
		||||
 | 
			
		||||
        return playlist_url
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CreateSubscriptionModal(LoginRequiredMixin, ModalMixin, CreateView):
 | 
			
		||||
    template_name = 'YtManagerApp/controls/subscription_create_modal.html'
 | 
			
		||||
    form_class = CreateSubscriptionForm
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        form.instance.user = self.request.user
 | 
			
		||||
        api = youtube.YoutubeAPI.build_public()
 | 
			
		||||
        try:
 | 
			
		||||
            form.instance.fetch_from_url(form.cleaned_data['playlist_url'], api)
 | 
			
		||||
        except youtube.InvalidURL as e:
 | 
			
		||||
            return self.modal_response(form, False, str(e))
 | 
			
		||||
        except ValueError as e:
 | 
			
		||||
            return self.modal_response(form, False, str(e))
 | 
			
		||||
        # 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.')
 | 
			
		||||
        # 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.')
 | 
			
		||||
        # except youtube.YoutubeException as 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 super().form_valid(form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UpdateSubscriptionForm(forms.ModelForm):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Subscription
 | 
			
		||||
        fields = ['name', 'parent_folder', 'auto_download',
 | 
			
		||||
                  'download_limit', 'download_order', 'delete_after_watched']
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        self.helper = FormHelper()
 | 
			
		||||
        self.helper.form_tag = False
 | 
			
		||||
        self.helper.layout = Layout(
 | 
			
		||||
            'name',
 | 
			
		||||
            'parent_folder',
 | 
			
		||||
            HTML('<hr>'),
 | 
			
		||||
            HTML('<h5>Download configuration overloads</h5>'),
 | 
			
		||||
            'auto_download',
 | 
			
		||||
            'download_limit',
 | 
			
		||||
            'download_order',
 | 
			
		||||
            'delete_after_watched'
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UpdateSubscriptionModal(LoginRequiredMixin, ModalMixin, UpdateView):
 | 
			
		||||
    template_name = 'YtManagerApp/controls/subscription_update_modal.html'
 | 
			
		||||
    model = Subscription
 | 
			
		||||
    form_class = UpdateSubscriptionForm
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DeleteSubscriptionForm(forms.Form):
 | 
			
		||||
    keep_downloaded_videos = forms.BooleanField(required=False, initial=False, label="Keep downloaded videos")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DeleteSubscriptionModal(LoginRequiredMixin, ModalMixin, FormMixin, DeleteView):
 | 
			
		||||
    template_name = 'YtManagerApp/controls/subscription_delete_modal.html'
 | 
			
		||||
    model = Subscription
 | 
			
		||||
    form_class = DeleteSubscriptionForm
 | 
			
		||||
 | 
			
		||||
    def delete(self, request, *args, **kwargs):
 | 
			
		||||
        self.object = self.get_object()
 | 
			
		||||
        form = self.get_form()
 | 
			
		||||
        if form.is_valid():
 | 
			
		||||
            return self.form_valid(form)
 | 
			
		||||
        else:
 | 
			
		||||
            return self.form_invalid(form)
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        self.object.delete_subscription(keep_downloaded_videos=form.cleaned_data['keep_downloaded_videos'])
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
							
								
								
									
										51
									
								
								app/YtManagerApp/views/settings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								app/YtManagerApp/views/settings.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,51 @@
 | 
			
		||||
from crispy_forms.helper import FormHelper
 | 
			
		||||
from crispy_forms.layout import Layout, HTML, Submit
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.contrib.auth.mixins import LoginRequiredMixin
 | 
			
		||||
from django.urls import reverse_lazy
 | 
			
		||||
from django.views.generic import UpdateView
 | 
			
		||||
 | 
			
		||||
from YtManagerApp.models import UserSettings
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SettingsForm(forms.ModelForm):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = UserSettings
 | 
			
		||||
        exclude = ['user']
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        self.helper = FormHelper()
 | 
			
		||||
        self.helper.form_class = 'form-horizontal'
 | 
			
		||||
        self.helper.label_class = 'col-lg-3'
 | 
			
		||||
        self.helper.field_class = 'col-lg-9'
 | 
			
		||||
        self.helper.layout = Layout(
 | 
			
		||||
            'mark_deleted_as_watched',
 | 
			
		||||
            'delete_watched',
 | 
			
		||||
            HTML('<h2>Download settings</h2>'),
 | 
			
		||||
            'auto_download',
 | 
			
		||||
            'download_path',
 | 
			
		||||
            'download_file_pattern',
 | 
			
		||||
            'download_format',
 | 
			
		||||
            'download_order',
 | 
			
		||||
            'download_global_limit',
 | 
			
		||||
            'download_subscription_limit',
 | 
			
		||||
            HTML('<h2>Subtitles download settings</h2>'),
 | 
			
		||||
            'download_subtitles',
 | 
			
		||||
            'download_subtitles_langs',
 | 
			
		||||
            'download_subtitles_all',
 | 
			
		||||
            'download_autogenerated_subtitles',
 | 
			
		||||
            'download_subtitles_format',
 | 
			
		||||
            Submit('submit', value='Save')
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SettingsView(LoginRequiredMixin, UpdateView):
 | 
			
		||||
    form_class = SettingsForm
 | 
			
		||||
    model = UserSettings
 | 
			
		||||
    template_name = 'YtManagerApp/settings.html'
 | 
			
		||||
    success_url = reverse_lazy('home')
 | 
			
		||||
 | 
			
		||||
    def get_object(self, queryset=None):
 | 
			
		||||
        obj, _ = self.model.objects.get_or_create(user=self.request.user)
 | 
			
		||||
        return obj
 | 
			
		||||
		Reference in New Issue
	
	Block a user