mirror of
				https://github.com/chibicitiberiu/ytsm.git
				synced 2024-02-24 05:43:31 +00:00 
			
		
		
		
	Big refactor
This commit is contained in:
		
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -1,5 +1,6 @@
 | 
			
		||||
.vs
 | 
			
		||||
.vscode
 | 
			
		||||
temp/
 | 
			
		||||
 | 
			
		||||
# Byte-compiled / optimized / DLL files
 | 
			
		||||
__pycache__/
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										6
									
								
								.idea/jsLibraryMappings.xml
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.idea/jsLibraryMappings.xml
									
									
									
										generated
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
<?xml version="1.0" encoding="UTF-8"?>
 | 
			
		||||
<project version="4">
 | 
			
		||||
  <component name="JavaScriptLibraryMappings">
 | 
			
		||||
    <file url="file://$PROJECT_DIR$" libraries="{bootstrap, jquery-3.3.1, popper}" />
 | 
			
		||||
  </component>
 | 
			
		||||
</project>
 | 
			
		||||
							
								
								
									
										833
									
								
								.idea/workspace.xml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										833
									
								
								.idea/workspace.xml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										3
									
								
								.idea/youtube-channel-manager.iml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								.idea/youtube-channel-manager.iml
									
									
									
										generated
									
									
									
								
							@@ -16,6 +16,9 @@
 | 
			
		||||
    <content url="file://$MODULE_DIR$" />
 | 
			
		||||
    <orderEntry type="inheritedJdk" />
 | 
			
		||||
    <orderEntry type="sourceFolder" forTests="false" />
 | 
			
		||||
    <orderEntry type="library" name="jquery-3.3.1" level="application" />
 | 
			
		||||
    <orderEntry type="library" name="popper" level="application" />
 | 
			
		||||
    <orderEntry type="library" name="bootstrap" level="application" />
 | 
			
		||||
  </component>
 | 
			
		||||
  <component name="TemplatesService">
 | 
			
		||||
    <option name="TEMPLATE_CONFIGURATION" value="Django" />
 | 
			
		||||
 
 | 
			
		||||
@@ -15,7 +15,6 @@ import os
 | 
			
		||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
 | 
			
		||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Quick-start development settings - unsuitable for production
 | 
			
		||||
# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
 | 
			
		||||
 | 
			
		||||
@@ -63,6 +62,7 @@ TEMPLATES = [
 | 
			
		||||
            'context_processors': [
 | 
			
		||||
                'django.template.context_processors.debug',
 | 
			
		||||
                'django.template.context_processors.request',
 | 
			
		||||
                'django.template.context_processors.media',
 | 
			
		||||
                'django.contrib.auth.context_processors.auth',
 | 
			
		||||
                'django.contrib.messages.context_processors.messages',
 | 
			
		||||
            ],
 | 
			
		||||
@@ -121,3 +121,22 @@ USE_TZ = True
 | 
			
		||||
# https://docs.djangoproject.com/en/1.11/howto/static-files/
 | 
			
		||||
 | 
			
		||||
STATIC_URL = '/static/'
 | 
			
		||||
MEDIA_URL = '/media/'
 | 
			
		||||
MEDIA_ROOT = 'D:\\Dev\\youtube-channel-manager\\temp\\media'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Application settings
 | 
			
		||||
# These should be moved to an ini file or something
 | 
			
		||||
DOWNLOADER_PATH = "D:\\Dev\\youtube-channel-manager\\temp\\download"
 | 
			
		||||
DOWNLOADER_FILE_PATTERN = "{channel}/{playlist}/S01E{playlist_index} - {title} [{id}].{ext}"
 | 
			
		||||
DOWNLOADER_FORMAT = "bestvideo+bestaudio"
 | 
			
		||||
DOWNLOADER_SUBTITLES = True
 | 
			
		||||
DOWNLOADER_SUBTITLES_LANGS = None
 | 
			
		||||
DOWNLOADER_AUTOGENERATED_SUBTITLES = False
 | 
			
		||||
DOWNLOADER_DEFAULT_DOWNLOAD_ENABLED = True
 | 
			
		||||
DOWNLOADER_DEFAULT_DOWNLOAD_LIMIT = 5        # -1 = no limit (all)
 | 
			
		||||
DOWNLOADER_DEFAULT_ORDER = 'playlist_index' # publish_date, playlist_index
 | 
			
		||||
MANAGER_MARK_DELETED_AS_WATCHED = True
 | 
			
		||||
MANAGER_DEFAULT_DELETE_AFTER_WATCHED = True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,8 @@ Including another URLconf
 | 
			
		||||
"""
 | 
			
		||||
from django.urls import path
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
from django.conf.urls.static import static
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
 | 
			
		||||
from YtManagerApp import views
 | 
			
		||||
 | 
			
		||||
@@ -26,5 +28,6 @@ urlpatterns = [
 | 
			
		||||
    path('ajax/delete_folder/<int:fid>/', views.ajax_delete_folder, name='ajax_delete_folder'),
 | 
			
		||||
    path('ajax/edit_subscription', views.ajax_edit_subscription, name='ajax_edit_subscription'),
 | 
			
		||||
    path('ajax/delete_subscription/<int:sid>/', views.ajax_delete_subscription, name='ajax_delete_subscription'),
 | 
			
		||||
    path('ajax/list_videos', views.ajax_list_videos, name='ajax_list_videos'),
 | 
			
		||||
    path(r'', views.index, name='home')
 | 
			
		||||
]
 | 
			
		||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										108
									
								
								YtManagerApp/appconfig.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								YtManagerApp/appconfig.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,108 @@
 | 
			
		||||
import logging
 | 
			
		||||
import os
 | 
			
		||||
import os.path
 | 
			
		||||
from shutil import copyfile
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
 | 
			
		||||
from .models import UserSettings
 | 
			
		||||
from .utils.customconfigparser import ConfigParserWithEnv
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AppConfig(object):
 | 
			
		||||
    __SETTINGS_FILE = 'config.ini'
 | 
			
		||||
    __LOG_FILE = 'log.log'
 | 
			
		||||
 | 
			
		||||
    DEFAULT_SETTINGS = {
 | 
			
		||||
        'global': {
 | 
			
		||||
            'YouTubeApiKey': 'AIzaSyBabzE4Bup77WexdLMa9rN9z-wJidEfNX8',
 | 
			
		||||
            'SynchronizationSchedule': '0 * * * * *',
 | 
			
		||||
            'SchedulerConcurrency': '2',
 | 
			
		||||
        },
 | 
			
		||||
        'user': {
 | 
			
		||||
            'MarkDeletedAsWatched': 'True',
 | 
			
		||||
            'DeleteWatched': 'True',
 | 
			
		||||
            'AutoDownload': 'True',
 | 
			
		||||
            'DownloadMaxAttempts': '3',
 | 
			
		||||
            'DownloadGlobalLimit': '',
 | 
			
		||||
            'DownloadSubscriptionLimit': '5',
 | 
			
		||||
            'DownloadOrder': 'playlist_index',
 | 
			
		||||
            'DownloadPath': '${env:USERPROFILE}${env:HOME}/Downloads',
 | 
			
		||||
            'DownloadFilePattern': '${channel}/${playlist}/S01E${playlist_index} - ${title} [${id}]',
 | 
			
		||||
            'DownloadFormat': 'bestvideo+bestaudio',
 | 
			
		||||
            'DownloadSubtitles': 'True',
 | 
			
		||||
            'DownloadAutogeneratedSubtitles': 'False',
 | 
			
		||||
            'DownloadSubtitlesAll': 'False',
 | 
			
		||||
            'DownloadSubtitlesLangs': 'en,ro',
 | 
			
		||||
            'DownloadSubtitlesFormat': '',
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        self.log_path = os.path.join(settings.BASE_DIR, 'config', AppConfig.__LOG_FILE)
 | 
			
		||||
        self.settings_path = os.path.join(settings.BASE_DIR, 'config', AppConfig.__SETTINGS_FILE)
 | 
			
		||||
 | 
			
		||||
        self.settings = ConfigParserWithEnv(defaults=AppConfig.DEFAULT_SETTINGS, allow_no_value=True)
 | 
			
		||||
        self.load_settings()
 | 
			
		||||
 | 
			
		||||
    def load_settings(self):
 | 
			
		||||
        if os.path.exists(self.settings_path):
 | 
			
		||||
            with open(self.settings_path, 'r') as f:
 | 
			
		||||
                self.settings.read_file(f)
 | 
			
		||||
 | 
			
		||||
    def save_settings(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.settings.write(f)
 | 
			
		||||
 | 
			
		||||
    def get_user_config(self, user: User) -> ConfigParserWithEnv:
 | 
			
		||||
        user_settings = UserSettings.find_by_user(user)
 | 
			
		||||
        if user_settings is not None:
 | 
			
		||||
            user_config = ConfigParserWithEnv(defaults=self.settings, allow_no_value=True)
 | 
			
		||||
            user_config.read_dict({
 | 
			
		||||
                'user': user_settings.to_dict()
 | 
			
		||||
            })
 | 
			
		||||
            return user_config
 | 
			
		||||
 | 
			
		||||
        return settings
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
instance: AppConfig = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __initialize_logger():
 | 
			
		||||
    # Parse log level
 | 
			
		||||
    log_level_str = instance.settings.get('global', 'LogLevel', fallback='INFO')
 | 
			
		||||
    levels = {
 | 
			
		||||
        'NOTSET': logging.NOTSET,
 | 
			
		||||
        'DEBUG': logging.DEBUG,
 | 
			
		||||
        'INFO': logging.INFO,
 | 
			
		||||
        'WARNING': logging.WARNING,
 | 
			
		||||
        'ERROR': logging.ERROR,
 | 
			
		||||
        'CRITICAL': logging.CRITICAL
 | 
			
		||||
    }
 | 
			
		||||
    if log_level_str.upper() not in levels:
 | 
			
		||||
        log_level_str = 'INFO'
 | 
			
		||||
 | 
			
		||||
    # Init
 | 
			
		||||
    logging.basicConfig(filename=instance.log_path, level=levels[log_level_str])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def initialize_config():
 | 
			
		||||
    global instance
 | 
			
		||||
    instance = AppConfig()
 | 
			
		||||
 | 
			
		||||
    # Load settings
 | 
			
		||||
    instance.load_settings()
 | 
			
		||||
 | 
			
		||||
    # Initialize logger
 | 
			
		||||
    __initialize_logger()
 | 
			
		||||
    logging.info('Application started!')
 | 
			
		||||
							
								
								
									
										11
									
								
								YtManagerApp/appmain.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								YtManagerApp/appmain.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
from .appconfig import initialize_config
 | 
			
		||||
from .scheduler import initialize_scheduler
 | 
			
		||||
from .management import setup_synchronization_job
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def main():
 | 
			
		||||
    initialize_config()
 | 
			
		||||
    initialize_scheduler()
 | 
			
		||||
    setup_synchronization_job()
 | 
			
		||||
    logging.info('Initialization complete.')
 | 
			
		||||
@@ -1,9 +1,13 @@
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class YtManagerAppConfig(AppConfig):
 | 
			
		||||
    name = 'YtManagerApp'
 | 
			
		||||
 | 
			
		||||
    def ready(self):
 | 
			
		||||
        from .management import SubscriptionManager
 | 
			
		||||
        SubscriptionManager.start_scheduler()
 | 
			
		||||
        # There seems to be a problem related to the auto-reload functionality where ready() is called twice
 | 
			
		||||
        # (in different processes). This seems like a good enough workaround (other than --noreload).
 | 
			
		||||
        if not os.getenv('RUN_MAIN', False):
 | 
			
		||||
            from .appmain import main
 | 
			
		||||
            main()
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										0
									
								
								YtManagerApp/management/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								YtManagerApp/management/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										110
									
								
								YtManagerApp/management/downloader.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								YtManagerApp/management/downloader.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,110 @@
 | 
			
		||||
from YtManagerApp.appconfig import instance as app_config
 | 
			
		||||
from YtManagerApp.management.jobs.download_video import schedule_download_video
 | 
			
		||||
from YtManagerApp.models import Video, Subscription
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
import logging
 | 
			
		||||
import requests
 | 
			
		||||
import mimetypes
 | 
			
		||||
import os
 | 
			
		||||
from urllib.parse import urljoin
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger('downloader')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_subscription_config(sub: Subscription):
 | 
			
		||||
    user_config = app_config.get_user_config(sub.user)
 | 
			
		||||
 | 
			
		||||
    enabled = sub.auto_download
 | 
			
		||||
    if enabled is None:
 | 
			
		||||
        enabled = user_config.getboolean('user', 'AutoDownload')
 | 
			
		||||
 | 
			
		||||
    global_limit = -1
 | 
			
		||||
    if len(user_config.get('user', 'DownloadGlobalLimit')) > 0:
 | 
			
		||||
        global_limit = user_config.getint('user', 'DownloadGlobalLimit')
 | 
			
		||||
 | 
			
		||||
    limit = sub.download_limit
 | 
			
		||||
    if limit is None:
 | 
			
		||||
        limit = -1
 | 
			
		||||
        if len(user_config.get('user', 'DownloadSubscriptionLimit')) > 0:
 | 
			
		||||
            limit = user_config.getint('user', 'DownloadSubscriptionLimit')
 | 
			
		||||
 | 
			
		||||
    order = sub.download_order
 | 
			
		||||
    if order is None:
 | 
			
		||||
        order = user_config.get('user', 'DownloadOrder')
 | 
			
		||||
 | 
			
		||||
    return enabled, global_limit, limit, order
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __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():
 | 
			
		||||
        __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(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(settings.MEDIA_URL, f"thumbs/{object_type}/{file_name}")
 | 
			
		||||
    return media_url
 | 
			
		||||
							
								
								
									
										0
									
								
								YtManagerApp/management/folders.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								YtManagerApp/management/folders.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								YtManagerApp/management/jobs/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								YtManagerApp/management/jobs/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										93
									
								
								YtManagerApp/management/jobs/download_video.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								YtManagerApp/management/jobs/download_video.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,93 @@
 | 
			
		||||
from YtManagerApp.models import Video
 | 
			
		||||
from YtManagerApp.scheduler import instance as scheduler
 | 
			
		||||
from YtManagerApp.appconfig import instance as app_config
 | 
			
		||||
import os
 | 
			
		||||
import youtube_dl
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger('video_downloader')
 | 
			
		||||
log_youtube_dl = log.getChild('youtube_dl')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __build_youtube_dl_params(video: Video, user_config):
 | 
			
		||||
    # resolve path
 | 
			
		||||
    format_dict = {
 | 
			
		||||
        'channel': video.subscription.channel.name,
 | 
			
		||||
        'channel_id': video.subscription.channel.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,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    user_config.set_additional_interpolation_options(**format_dict)
 | 
			
		||||
 | 
			
		||||
    download_path = user_config.get('user', 'DownloadPath')
 | 
			
		||||
    output_pattern = user_config.get('user', 'DownloadFilePattern')
 | 
			
		||||
    output_path = os.path.join(download_path, output_pattern)
 | 
			
		||||
    output_path = os.path.normpath(output_path)
 | 
			
		||||
 | 
			
		||||
    youtube_dl_params = {
 | 
			
		||||
        'logger': log_youtube_dl,
 | 
			
		||||
        'format': user_config.get('user', 'DownloadFormat'),
 | 
			
		||||
        'outtmpl': output_path,
 | 
			
		||||
        'writethumbnail': True,
 | 
			
		||||
        'writedescription': True,
 | 
			
		||||
        'writesubtitles': user_config.getboolean('user', 'DownloadSubtitles'),
 | 
			
		||||
        'writeautomaticsub': user_config.getboolean('user', 'DownloadAutogeneratedSubtitles'),
 | 
			
		||||
        'allsubtitles': user_config.getboolean('user', 'DownloadSubtitlesAll'),
 | 
			
		||||
        'postprocessors': [
 | 
			
		||||
            {
 | 
			
		||||
                'key': 'FFmpegMetadataPP'
 | 
			
		||||
            },
 | 
			
		||||
        ]
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    sub_langs = user_config.get('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 = user_config.get('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)
 | 
			
		||||
 | 
			
		||||
    user_config = app_config.get_user_config(video.subscription.user)
 | 
			
		||||
    max_attempts = user_config.getint('user', 'DownloadMaxAttempts', fallback=3)
 | 
			
		||||
 | 
			
		||||
    youtube_dl_params, output_path = __build_youtube_dl_params(video, user_config)
 | 
			
		||||
    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.error('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)
 | 
			
		||||
        scheduler.add_job(download_video, args=[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):
 | 
			
		||||
    """
 | 
			
		||||
    Schedules a download video job to run immediately.
 | 
			
		||||
    :param video:
 | 
			
		||||
    :return:
 | 
			
		||||
    """
 | 
			
		||||
    scheduler.add_job(download_video, args=[video, 1])
 | 
			
		||||
							
								
								
									
										46
									
								
								YtManagerApp/management/jobs/synchronize.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								YtManagerApp/management/jobs/synchronize.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,46 @@
 | 
			
		||||
from YtManagerApp.models import *
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def synchronize():
 | 
			
		||||
    logger = logging.getLogger('sync')
 | 
			
		||||
 | 
			
		||||
    logger.info("Running scheduled synchronization... ")
 | 
			
		||||
 | 
			
		||||
    # Sync subscribed playlists/channels
 | 
			
		||||
    yt_api = YoutubeAPI.build_public()
 | 
			
		||||
    for subscription in Subscription.objects.all():
 | 
			
		||||
        SubscriptionManager.__synchronize(subscription, yt_api)
 | 
			
		||||
 | 
			
		||||
    # Fetch thumbnails
 | 
			
		||||
    logger.info("Fetching channel thumbnails... ")
 | 
			
		||||
    for ch in Channel.objects.filter(icon_default__istartswith='http'):
 | 
			
		||||
        ch.icon_default = SubscriptionManager.__fetch_thumbnail(ch.icon_default, 'channel', ch.channel_id, 'default')
 | 
			
		||||
        ch.save()
 | 
			
		||||
 | 
			
		||||
    for ch in Channel.objects.filter(icon_best__istartswith='http'):
 | 
			
		||||
        ch.icon_best = SubscriptionManager.__fetch_thumbnail(ch.icon_best, 'channel', ch.channel_id, 'best')
 | 
			
		||||
        ch.save()
 | 
			
		||||
 | 
			
		||||
    logger.info("Fetching subscription thumbnails... ")
 | 
			
		||||
    for sub in Subscription.objects.filter(icon_default__istartswith='http'):
 | 
			
		||||
        sub.icon_default = SubscriptionManager.__fetch_thumbnail(sub.icon_default, 'sub', sub.playlist_id, 'default')
 | 
			
		||||
        sub.save()
 | 
			
		||||
 | 
			
		||||
    for sub in Subscription.objects.filter(icon_best__istartswith='http'):
 | 
			
		||||
        sub.icon_best = SubscriptionManager.__fetch_thumbnail(sub.icon_best, 'sub', sub.playlist_id, 'best')
 | 
			
		||||
        sub.save()
 | 
			
		||||
 | 
			
		||||
    logger.info("Fetching video thumbnails... ")
 | 
			
		||||
    for vid in Video.objects.filter(icon_default__istartswith='http'):
 | 
			
		||||
        vid.icon_default = SubscriptionManager.__fetch_thumbnail(vid.icon_default, 'video', vid.video_id, 'default')
 | 
			
		||||
        vid.save()
 | 
			
		||||
 | 
			
		||||
    for vid in Video.objects.filter(icon_best__istartswith='http'):
 | 
			
		||||
        vid.icon_best = SubscriptionManager.__fetch_thumbnail(vid.icon_best, 'video', vid.video_id, 'best')
 | 
			
		||||
        vid.save()
 | 
			
		||||
 | 
			
		||||
    print("Downloading videos...")
 | 
			
		||||
    Downloader.download_all()
 | 
			
		||||
 | 
			
		||||
    print("Synchronization finished.")
 | 
			
		||||
@@ -1,7 +1,18 @@
 | 
			
		||||
from .models import SubscriptionFolder, Subscription, Video, Channel
 | 
			
		||||
from .youtube import YoutubeAPI, YoutubeChannelInfo, YoutubePlaylistItem
 | 
			
		||||
from YtManagerApp.models import SubscriptionFolder, Subscription, Video, Channel
 | 
			
		||||
from YtManagerApp.utils.youtube import YoutubeAPI, YoutubeChannelInfo, YoutubePlaylistItem
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from apscheduler.schedulers.background import BackgroundScheduler
 | 
			
		||||
import os
 | 
			
		||||
import os.path
 | 
			
		||||
import requests
 | 
			
		||||
from urllib.parse import urljoin
 | 
			
		||||
import mimetypes
 | 
			
		||||
import youtube_dl
 | 
			
		||||
 | 
			
		||||
from YtManagerApp.scheduler import instance as scheduler
 | 
			
		||||
from YtManagerApp.appconfig import instance as app_config
 | 
			
		||||
from apscheduler.triggers.cron import CronTrigger
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FolderManager(object):
 | 
			
		||||
 | 
			
		||||
@@ -45,6 +56,18 @@ class FolderManager(object):
 | 
			
		||||
        folder = SubscriptionFolder.objects.get(id=fid)
 | 
			
		||||
        folder.delete()
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def list_videos(fid: int):
 | 
			
		||||
        folder = SubscriptionFolder.objects.get(id=fid)
 | 
			
		||||
        folder_list = []
 | 
			
		||||
        queue = [folder]
 | 
			
		||||
        while len(queue) > 0:
 | 
			
		||||
            folder = queue.pop()
 | 
			
		||||
            folder_list.append(folder)
 | 
			
		||||
            queue.extend(SubscriptionFolder.objects.filter(parent=folder))
 | 
			
		||||
 | 
			
		||||
        return Video.objects.filter(subscription__parent_folder__in=folder_list).order_by('-publish_date')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SubscriptionManager(object):
 | 
			
		||||
    __scheduler = BackgroundScheduler()
 | 
			
		||||
@@ -53,7 +76,6 @@ class SubscriptionManager(object):
 | 
			
		||||
    def create_or_edit(sid, url, name, parent_id):
 | 
			
		||||
        # Create or edit
 | 
			
		||||
        if sid == '#':
 | 
			
		||||
            sub = Subscription()
 | 
			
		||||
            SubscriptionManager.create(url, parent_id, YoutubeAPI.build_public())
 | 
			
		||||
        else:
 | 
			
		||||
            sub = Subscription.objects.get(id=int(sid))
 | 
			
		||||
@@ -100,6 +122,11 @@ class SubscriptionManager(object):
 | 
			
		||||
 | 
			
		||||
        sub.save()
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def list_videos(fid: int):
 | 
			
		||||
        sub = Subscription.objects.get(id=fid)
 | 
			
		||||
        return Video.objects.filter(subscription=sub).order_by('playlist_index')
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def __get_or_create_channel(url_type, url_id, yt_api: YoutubeAPI):
 | 
			
		||||
 | 
			
		||||
@@ -173,12 +200,79 @@ class SubscriptionManager(object):
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def __synchronize_all():
 | 
			
		||||
        print("Running scheduled synchronization... ")
 | 
			
		||||
 | 
			
		||||
        # Sync subscribed playlists/channels
 | 
			
		||||
        yt_api = YoutubeAPI.build_public()
 | 
			
		||||
        for subscription in Subscription.objects.all():
 | 
			
		||||
            SubscriptionManager.__synchronize(subscription, yt_api)
 | 
			
		||||
 | 
			
		||||
        # Fetch thumbnails
 | 
			
		||||
        print("Fetching channel thumbnails... ")
 | 
			
		||||
        for ch in Channel.objects.filter(icon_default__istartswith='http'):
 | 
			
		||||
            ch.icon_default = SubscriptionManager.__fetch_thumbnail(ch.icon_default, 'channel', ch.channel_id, 'default')
 | 
			
		||||
            ch.save()
 | 
			
		||||
 | 
			
		||||
        for ch in Channel.objects.filter(icon_best__istartswith='http'):
 | 
			
		||||
            ch.icon_best = SubscriptionManager.__fetch_thumbnail(ch.icon_best, 'channel', ch.channel_id, 'best')
 | 
			
		||||
            ch.save()
 | 
			
		||||
 | 
			
		||||
        print("Fetching subscription thumbnails... ")
 | 
			
		||||
        for sub in Subscription.objects.filter(icon_default__istartswith='http'):
 | 
			
		||||
            sub.icon_default = SubscriptionManager.__fetch_thumbnail(sub.icon_default, 'sub', sub.playlist_id, 'default')
 | 
			
		||||
            sub.save()
 | 
			
		||||
 | 
			
		||||
        for sub in Subscription.objects.filter(icon_best__istartswith='http'):
 | 
			
		||||
            sub.icon_best = SubscriptionManager.__fetch_thumbnail(sub.icon_best, 'sub', sub.playlist_id, 'best')
 | 
			
		||||
            sub.save()
 | 
			
		||||
 | 
			
		||||
        print("Fetching video thumbnails... ")
 | 
			
		||||
        for vid in Video.objects.filter(icon_default__istartswith='http'):
 | 
			
		||||
            vid.icon_default = SubscriptionManager.__fetch_thumbnail(vid.icon_default, 'video', vid.video_id, 'default')
 | 
			
		||||
            vid.save()
 | 
			
		||||
 | 
			
		||||
        for vid in Video.objects.filter(icon_best__istartswith='http'):
 | 
			
		||||
            vid.icon_best = SubscriptionManager.__fetch_thumbnail(vid.icon_best, 'video', vid.video_id, 'best')
 | 
			
		||||
            vid.save()
 | 
			
		||||
 | 
			
		||||
        print("Downloading videos...")
 | 
			
		||||
        Downloader.download_all()
 | 
			
		||||
 | 
			
		||||
        print("Synchronization finished.")
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def __fetch_thumbnail(url, object_type, identifier, quality):
 | 
			
		||||
 | 
			
		||||
        # Make request to obtain mime type
 | 
			
		||||
        response = requests.get(url, stream=True)
 | 
			
		||||
        ext = mimetypes.guess_extension(response.headers['Content-Type'])
 | 
			
		||||
 | 
			
		||||
        # Build file path
 | 
			
		||||
        file_name = f"{identifier}-{quality}{ext}"
 | 
			
		||||
        abs_path_dir = os.path.join(settings.MEDIA_ROOT, "thumbs", object_type)
 | 
			
		||||
        abs_path = os.path.join(abs_path_dir, file_name)
 | 
			
		||||
 | 
			
		||||
        # Store image
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
        # Return
 | 
			
		||||
        media_url = urljoin(settings.MEDIA_URL, f"thumbs/{object_type}/{file_name}")
 | 
			
		||||
        return media_url
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def start_scheduler():
 | 
			
		||||
        SubscriptionManager.__scheduler.add_job(SubscriptionManager.__synchronize_all, 'cron',
 | 
			
		||||
                                                hour='*', minute=44, max_instances=1)
 | 
			
		||||
                                                hour='*', minute=38, max_instances=1)
 | 
			
		||||
        SubscriptionManager.__scheduler.start()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def setup_synchronization_job():
 | 
			
		||||
    trigger = CronTrigger.from_crontab(app_config.get('global', 'SynchronizationSchedule'))
 | 
			
		||||
    scheduler.add_job(synchronize_all, trigger, max_instances=1)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def synchronize_all():
 | 
			
		||||
    pass
 | 
			
		||||
							
								
								
									
										0
									
								
								YtManagerApp/management/subscriptions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								YtManagerApp/management/subscriptions.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										33
									
								
								YtManagerApp/migrations/0007_auto_20181009_0209.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								YtManagerApp/migrations/0007_auto_20181009_0209.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,33 @@
 | 
			
		||||
# Generated by Django 2.1.2 on 2018-10-08 23:09
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('YtManagerApp', '0006_auto_20181008_0037'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='downloader_enabled',
 | 
			
		||||
            field=models.BooleanField(null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='downloader_limit',
 | 
			
		||||
            field=models.IntegerField(null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='downloader_order',
 | 
			
		||||
            field=models.TextField(null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='subscription',
 | 
			
		||||
            name='manager_delete_after_watched',
 | 
			
		||||
            field=models.BooleanField(null=True),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -1,4 +1,67 @@
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserSettings(models.Model):
 | 
			
		||||
    user = models.OneToOneField(User, on_delete=models.CASCADE)
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
    @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):
 | 
			
		||||
@@ -52,6 +115,13 @@ class Subscription(models.Model):
 | 
			
		||||
    channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
 | 
			
		||||
    icon_default = models.TextField()
 | 
			
		||||
    icon_best = models.TextField()
 | 
			
		||||
    user = models.ForeignKey(User, on_delete=models.CASCADE)
 | 
			
		||||
 | 
			
		||||
    # overrides
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								YtManagerApp/scheduler.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								YtManagerApp/scheduler.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
import logging
 | 
			
		||||
from apscheduler.schedulers.background import BackgroundScheduler
 | 
			
		||||
 | 
			
		||||
from .appconfig import instance as app_config
 | 
			
		||||
 | 
			
		||||
instance: BackgroundScheduler = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def initialize_scheduler():
 | 
			
		||||
    global instance
 | 
			
		||||
    logger = logging.getLogger('scheduler')
 | 
			
		||||
    executors = {
 | 
			
		||||
        'default': {
 | 
			
		||||
            'type': 'threadpool',
 | 
			
		||||
            'max_workers': int(app_config.get('global', 'SchedulerConcurrency'))
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    instance = BackgroundScheduler(logger=logger, executors=executors)
 | 
			
		||||
    instance.start()
 | 
			
		||||
@@ -27,5 +27,28 @@
 | 
			
		||||
    transform: rotate(0deg); }
 | 
			
		||||
  100% {
 | 
			
		||||
    transform: rotate(360deg); } }
 | 
			
		||||
.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 .video-icon-yes {
 | 
			
		||||
  color: #6c757d; }
 | 
			
		||||
.video-gallery .video-icon-no {
 | 
			
		||||
  color: #cccccc; }
 | 
			
		||||
 | 
			
		||||
/*# sourceMappingURL=style.css.map */
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
"version": 3,
 | 
			
		||||
"mappings": "AAEA,gCAAgC;AAChC,wBAAyB;EACrB,OAAO,EAAE,OAAO;;AAGpB,wBAAyB;EACrB,OAAO,EAAE,OAAO;;AAGpB,uBAAuB;AACvB,kBAAmB;EACf,OAAO,EAAE,YAAY;EACrB,KAAK,EAAE,IAAI;EACX,MAAM,EAAE,IAAI;;AAGhB,wBAAyB;EACrB,OAAO,EAAE,GAAG;EACZ,OAAO,EAAE,KAAK;EACd,KAAK,EAAE,IAAI;EACX,MAAM,EAAE,IAAI;EACZ,MAAM,EAAE,GAAG;EACX,aAAa,EAAE,GAAG;EAClB,MAAM,EAAE,iBAAuB;EAC/B,YAAY,EAAE,uCAAmD;EACjE,SAAS,EAAE,sCAAsC;;AAGrD,4BAOC;EANG,EAAG;IACC,SAAS,EAAE,YAAY;EAE3B,IAAK;IACD,SAAS,EAAE,cAAc",
 | 
			
		||||
"mappings": "AAEA,gCAAgC;AAChC,wBAAyB;EACrB,OAAO,EAAE,OAAO;;AAGpB,wBAAyB;EACrB,OAAO,EAAE,OAAO;;AAGpB,uBAAuB;AACvB,kBAAmB;EACf,OAAO,EAAE,YAAY;EACrB,KAAK,EAAE,IAAI;EACX,MAAM,EAAE,IAAI;;AAGhB,wBAAyB;EACrB,OAAO,EAAE,GAAG;EACZ,OAAO,EAAE,KAAK;EACd,KAAK,EAAE,IAAI;EACX,MAAM,EAAE,IAAI;EACZ,MAAM,EAAE,GAAG;EACX,aAAa,EAAE,GAAG;EAClB,MAAM,EAAE,iBAAuB;EAC/B,YAAY,EAAE,uCAAmD;EACjE,SAAS,EAAE,sCAAsC;;AAGrD,4BAOC;EANG,EAAG;IACC,SAAS,EAAE,YAAY;EAE3B,IAAK;IACD,SAAS,EAAE,cAAc;AAK7B,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;AAKjC,8BAAgB;EACZ,KAAK,EAAE,OAAO;AAElB,6BAAe;EACX,KAAK,EAAE,OAAO",
 | 
			
		||||
"sources": ["style.scss"],
 | 
			
		||||
"names": [],
 | 
			
		||||
"file": "style.css"
 | 
			
		||||
 
 | 
			
		||||
@@ -35,4 +35,46 @@ $accent-color: #007bff;
 | 
			
		||||
    100% {
 | 
			
		||||
        transform: rotate(360deg);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.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;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .video-icon-yes {
 | 
			
		||||
        color: #6c757d;
 | 
			
		||||
    }
 | 
			
		||||
    .video-icon-no {
 | 
			
		||||
        color: #cccccc;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										92
									
								
								YtManagerApp/static/YtManagerApp/import/typicons/LICENCE.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								YtManagerApp/static/YtManagerApp/import/typicons/LICENCE.md
									
									
									
									
									
										Normal file
									
								
							@@ -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
									
								
								YtManagerApp/static/YtManagerApp/import/typicons/demo.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										406
									
								
								YtManagerApp/static/YtManagerApp/import/typicons/demo.html
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1040
									
								
								YtManagerApp/static/YtManagerApp/import/typicons/typicons.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1040
									
								
								YtManagerApp/static/YtManagerApp/import/typicons/typicons.css
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								YtManagerApp/static/YtManagerApp/import/typicons/typicons.eot
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								YtManagerApp/static/YtManagerApp/import/typicons/typicons.eot
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										1
									
								
								YtManagerApp/static/YtManagerApp/import/typicons/typicons.min.css
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								YtManagerApp/static/YtManagerApp/import/typicons/typicons.min.css
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1180
									
								
								YtManagerApp/static/YtManagerApp/import/typicons/typicons.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1180
									
								
								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  | 
							
								
								
									
										
											BIN
										
									
								
								YtManagerApp/static/YtManagerApp/import/typicons/typicons.ttf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								YtManagerApp/static/YtManagerApp/import/typicons/typicons.ttf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								YtManagerApp/static/YtManagerApp/import/typicons/typicons.woff
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								YtManagerApp/static/YtManagerApp/import/typicons/typicons.woff
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							@@ -21,18 +21,18 @@
 | 
			
		||||
    <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" >
 | 
			
		||||
                <i class="material-icons" aria-hidden="true">add</i>
 | 
			
		||||
                <span class="typcn typcn-plus" aria-hidden="true"></span>
 | 
			
		||||
            </button>
 | 
			
		||||
            <button id="btn_create_folder" type="button" class="btn btn-secondary">
 | 
			
		||||
                <i class="material-icons" aria-hidden="true">create_new_folder</i>
 | 
			
		||||
                <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" >
 | 
			
		||||
                <i class="material-icons" aria-hidden="true">edit</i>
 | 
			
		||||
                <span class="typcn typcn-edit" aria-hidden="true"></span>
 | 
			
		||||
            </button>
 | 
			
		||||
            <button id="btn_delete_node" type="button" class="btn btn-secondary" >
 | 
			
		||||
                <i class="material-icons" aria-hidden="true">delete</i>
 | 
			
		||||
                <span class="typcn typcn-trash" aria-hidden="true"></span>
 | 
			
		||||
            </button>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
@@ -41,4 +41,10 @@
 | 
			
		||||
        <p>Loading...</p>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block detail %}
 | 
			
		||||
    <div id="main_detail">
 | 
			
		||||
 | 
			
		||||
    </div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -279,10 +279,10 @@ function tree_Initialize()
 | 
			
		||||
        },
 | 
			
		||||
        types : {
 | 
			
		||||
            folder : {
 | 
			
		||||
                icon : "material-icons material-folder"
 | 
			
		||||
                icon : "typcn typcn-folder"
 | 
			
		||||
            },
 | 
			
		||||
            sub : {
 | 
			
		||||
                icon : "material-icons material-person",
 | 
			
		||||
                icon : "typcn typcn-user",
 | 
			
		||||
                max_depth : 0
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
@@ -314,6 +314,13 @@ function tree_ValidateChange(operation, node, parent, position, more)
 | 
			
		||||
function tree_OnSelectionChanged(e, data)
 | 
			
		||||
{
 | 
			
		||||
    node = data.instance.get_selected(true)[0];
 | 
			
		||||
    $.post("{% url 'ajax_list_videos' %}", {
 | 
			
		||||
        type: node.type,
 | 
			
		||||
        id: node.id.replace('folder', '').replace('sub', ''),
 | 
			
		||||
        csrfmiddlewaretoken: '{{ csrf_token }}'
 | 
			
		||||
    }).done(function (result) {
 | 
			
		||||
        $("#main_detail").html(result);
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
///
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@
 | 
			
		||||
        <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 href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
 | 
			
		||||
        <link rel="stylesheet" href="{% static 'YtManagerApp/import/typicons/typicons.min.css' %}" />
 | 
			
		||||
        <link rel="stylesheet" href="{% static 'YtManagerApp/css/style.css' %}">
 | 
			
		||||
        {% block stylesheets %}
 | 
			
		||||
        {% endblock %}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										38
									
								
								YtManagerApp/templates/YtManagerApp/main_videos.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								YtManagerApp/templates/YtManagerApp/main_videos.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,38 @@
 | 
			
		||||
<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">{{ 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">
 | 
			
		||||
                            <a class="dropdown-item" href="#">Mark {{ video.watched | yesno:"not watched,watched" }}</a>
 | 
			
		||||
                            {% if video.downloaded_path %}
 | 
			
		||||
                                <a class="dropdown-item" href="#">Delete downloaded</a>
 | 
			
		||||
                            {% else %}
 | 
			
		||||
                                <a class="dropdown-item" href="#">Download</a>
 | 
			
		||||
                            {% endif %}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										0
									
								
								YtManagerApp/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								YtManagerApp/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								YtManagerApp/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								YtManagerApp/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										114
									
								
								YtManagerApp/utils/customconfigparser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								YtManagerApp/utils/customconfigparser.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,114 @@
 | 
			
		||||
import os
 | 
			
		||||
import os.path
 | 
			
		||||
import re
 | 
			
		||||
from configparser import Interpolation, NoSectionError, NoOptionError, InterpolationMissingOptionError, \
 | 
			
		||||
    InterpolationDepthError, InterpolationSyntaxError, ConfigParser
 | 
			
		||||
 | 
			
		||||
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 __init__(self, **kwargs):
 | 
			
		||||
        self.__kwargs = kwargs
 | 
			
		||||
 | 
			
		||||
    def set_additional_options(self, **kwargs):
 | 
			
		||||
        self.__kwargs = kwargs
 | 
			
		||||
 | 
			
		||||
    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):
 | 
			
		||||
        if option in self.__kwargs:
 | 
			
		||||
            return self.__kwargs[option]
 | 
			
		||||
        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,))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ConfigParserWithEnv(ConfigParser):
 | 
			
		||||
    _DEFAULT_INTERPOLATION = ExtendedInterpolatorWithEnv()
 | 
			
		||||
 | 
			
		||||
    def set_additional_interpolation_options(self, **kwargs):
 | 
			
		||||
        """
 | 
			
		||||
        Sets additional options to be used in interpolation.
 | 
			
		||||
        Only works with ExtendedInterpolatorWithEnv
 | 
			
		||||
        :param kwargs:
 | 
			
		||||
        :return:
 | 
			
		||||
        """
 | 
			
		||||
        if isinstance(super()._interpolation, ExtendedInterpolatorWithEnv):
 | 
			
		||||
            super()._interpolation.set_additional_options(**kwargs)
 | 
			
		||||
@@ -20,7 +20,8 @@ def get_children_recurse(parent_id):
 | 
			
		||||
        children.append({
 | 
			
		||||
            "id": "sub" + str(sub.id),
 | 
			
		||||
            "type": "sub",
 | 
			
		||||
            "text": sub.name
 | 
			
		||||
            "text": sub.name,
 | 
			
		||||
            "icon": sub.icon_default
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
    return children
 | 
			
		||||
@@ -82,6 +83,21 @@ def ajax_delete_subscription(request: HttpRequest, sid):
 | 
			
		||||
    return HttpResponse()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def ajax_list_videos(request: HttpRequest):
 | 
			
		||||
    if request.method == 'POST':
 | 
			
		||||
        type = request.POST['type']
 | 
			
		||||
        id = request.POST['id']
 | 
			
		||||
        context = {}
 | 
			
		||||
 | 
			
		||||
        if type == 'sub':
 | 
			
		||||
            context['videos'] = SubscriptionManager.list_videos(int(id))
 | 
			
		||||
        else:
 | 
			
		||||
            context['videos'] = FolderManager.list_videos(int(id))
 | 
			
		||||
 | 
			
		||||
        return render(request, 'YtManagerApp/main_videos.html', context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def index(request: HttpRequest):
 | 
			
		||||
    context = {}
 | 
			
		||||
    return render(request, 'YtManagerApp/index.html', context)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										61
									
								
								config/config.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								config/config.ini
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,61 @@
 | 
			
		||||
; Use $<env:environment_variable> to use the value of an environment variable.
 | 
			
		||||
; The global section contains settings that apply to the entire server
 | 
			
		||||
[global]
 | 
			
		||||
; YouTube API key - get this from your user account
 | 
			
		||||
YoutubeApiKey=AIzaSyBabzE4Bup77WexdLMa9rN9z-wJidEfNX8
 | 
			
		||||
 | 
			
		||||
; Specifies the synchronization schedule, in crontab format.
 | 
			
		||||
; Format: <minute> <hour> <day-of-month> <month-of-year> <year>
 | 
			
		||||
SynchronizationSchedule=0 * * * * *
 | 
			
		||||
 | 
			
		||||
; Number of threads running the scheduler
 | 
			
		||||
; Since most of the jobs scheduled are downloads, there is no advantage to having
 | 
			
		||||
; a higher concurrency
 | 
			
		||||
SchedulerConcurrency=2
 | 
			
		||||
 | 
			
		||||
; Log level
 | 
			
		||||
LogLevel=DEBUG
 | 
			
		||||
 | 
			
		||||
; Default user settings
 | 
			
		||||
[user]
 | 
			
		||||
; When a video is deleted on the system, it will be marked as 'watched'
 | 
			
		||||
MarkDeletedAsWatched=True
 | 
			
		||||
 | 
			
		||||
; Videos marked as watched are automatically deleted
 | 
			
		||||
DeleteWatched=True
 | 
			
		||||
 | 
			
		||||
; Enable automatic downloading
 | 
			
		||||
AutoDownload=True
 | 
			
		||||
 | 
			
		||||
; Limit the total number of videos downloaded (-1 or empty = no limit)
 | 
			
		||||
DownloadGlobalLimit=
 | 
			
		||||
 | 
			
		||||
; Limit the numbers of videos per subscription (-1 or empty = no limit)
 | 
			
		||||
DownloadSubscriptionLimit=5
 | 
			
		||||
 | 
			
		||||
; Number of download attempts
 | 
			
		||||
DownloadMaxAttempts=3
 | 
			
		||||
 | 
			
		||||
; Download order
 | 
			
		||||
; Options: playlist_index, publish_date, name.
 | 
			
		||||
; Use - to reverse order (e.g. -publish_date means to order by publish date descending)
 | 
			
		||||
DownloadOrder=playlist_index
 | 
			
		||||
 | 
			
		||||
; Path where downloaded videos are stored
 | 
			
		||||
;DownloadPath=${env:USERPROFILE}${env:HOME}/Downloads
 | 
			
		||||
DownloadPath=D:\\Dev\\youtube-channel-manager\\temp\\download
 | 
			
		||||
 | 
			
		||||
; A pattern which describes how downloaded files are organized. Extensions are automatically appended.
 | 
			
		||||
; Supported fields: channel, channel_id, playlist, playlist_id, playlist_index, title, id
 | 
			
		||||
; The default pattern should work pretty well with Plex
 | 
			
		||||
DownloadFilePattern=${channel}/${playlist}/S01E${playlist_index} - ${title} [${id}]
 | 
			
		||||
 | 
			
		||||
; Download format that will be passed to youtube-dl. See the youtube-dl documentation for more details.
 | 
			
		||||
DownloadFormat=bestvideo+bestaudio
 | 
			
		||||
 | 
			
		||||
; Subtitles - these options match the youtube-dl options
 | 
			
		||||
DownloadSubtitles=True
 | 
			
		||||
DownloadAutogeneratedSubtitles=False
 | 
			
		||||
DownloadSubtitlesAll=False
 | 
			
		||||
DownloadSubtitlesLangs=en,ro
 | 
			
		||||
DownloadSubtitlesFormat=
 | 
			
		||||
							
								
								
									
										61
									
								
								config/config.ini.default
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								config/config.ini.default
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,61 @@
 | 
			
		||||
; Use $<env:environment_variable> to use the value of an environment variable.
 | 
			
		||||
; The global section contains settings that apply to the entire server
 | 
			
		||||
[global]
 | 
			
		||||
; YouTube API key - get this from your user account
 | 
			
		||||
YoutubeApiKey=AIzaSyBabzE4Bup77WexdLMa9rN9z-wJidEfNX8
 | 
			
		||||
 | 
			
		||||
; Specifies the synchronization schedule, in crontab format.
 | 
			
		||||
; Format: <minute> <hour> <day-of-month> <month-of-year> <year>
 | 
			
		||||
SynchronizationSchedule=0 * * * * *
 | 
			
		||||
 | 
			
		||||
; Number of threads running the scheduler
 | 
			
		||||
; Since most of the jobs scheduled are downloads, there is no advantage to having
 | 
			
		||||
; a higher concurrency
 | 
			
		||||
SchedulerConcurrency=2
 | 
			
		||||
 | 
			
		||||
; Log level
 | 
			
		||||
LogLevel=DEBUG
 | 
			
		||||
 | 
			
		||||
; Default user settings
 | 
			
		||||
[user]
 | 
			
		||||
; When a video is deleted on the system, it will be marked as 'watched'
 | 
			
		||||
MarkDeletedAsWatched=True
 | 
			
		||||
 | 
			
		||||
; Videos marked as watched are automatically deleted
 | 
			
		||||
DeleteWatched=True
 | 
			
		||||
 | 
			
		||||
; Enable automatic downloading
 | 
			
		||||
AutoDownload=True
 | 
			
		||||
 | 
			
		||||
; Limit the total number of videos downloaded (-1 or empty = no limit)
 | 
			
		||||
DownloadGlobalLimit=
 | 
			
		||||
 | 
			
		||||
; Limit the numbers of videos per subscription (-1 or empty = no limit)
 | 
			
		||||
DownloadSubscriptionLimit=5
 | 
			
		||||
 | 
			
		||||
; Number of download attempts
 | 
			
		||||
DownloadMaxAttempts=3
 | 
			
		||||
 | 
			
		||||
; Download order
 | 
			
		||||
; Options: playlist_index, publish_date, name.
 | 
			
		||||
; Use - to reverse order (e.g. -publish_date means to order by publish date descending)
 | 
			
		||||
DownloadOrder=playlist_index
 | 
			
		||||
 | 
			
		||||
; Path where downloaded videos are stored
 | 
			
		||||
;DownloadPath=${env:USERPROFILE}${env:HOME}/Downloads
 | 
			
		||||
DownloadPath=D:\\Dev\\youtube-channel-manager\\temp\\download
 | 
			
		||||
 | 
			
		||||
; A pattern which describes how downloaded files are organized. Extensions are automatically appended.
 | 
			
		||||
; Supported fields: channel, channel_id, playlist, playlist_id, playlist_index, title, id
 | 
			
		||||
; The default pattern should work pretty well with Plex
 | 
			
		||||
DownloadFilePattern=${channel}/${playlist}/S01E${playlist_index} - ${title} [${id}]
 | 
			
		||||
 | 
			
		||||
; Download format that will be passed to youtube-dl. See the youtube-dl documentation for more details.
 | 
			
		||||
DownloadFormat=bestvideo+bestaudio
 | 
			
		||||
 | 
			
		||||
; Subtitles - these options match the youtube-dl options
 | 
			
		||||
DownloadSubtitles=True
 | 
			
		||||
DownloadAutogeneratedSubtitles=False
 | 
			
		||||
DownloadSubtitlesAll=False
 | 
			
		||||
DownloadSubtitlesLangs=en,ro
 | 
			
		||||
DownloadSubtitlesFormat=
 | 
			
		||||
		Reference in New Issue
	
	Block a user