mirror of
https://github.com/chibicitiberiu/ytsm.git
synced 2024-02-24 05:43:31 +00:00
Big refactor
This commit is contained in:
parent
291da16461
commit
ae77251883
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,6 @@
|
|||||||
.vs
|
.vs
|
||||||
.vscode
|
.vscode
|
||||||
|
temp/
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__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$" />
|
<content url="file://$MODULE_DIR$" />
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<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>
|
||||||
<component name="TemplatesService">
|
<component name="TemplatesService">
|
||||||
<option name="TEMPLATE_CONFIGURATION" value="Django" />
|
<option name="TEMPLATE_CONFIGURATION" value="Django" />
|
||||||
|
@ -15,7 +15,6 @@ import os
|
|||||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
|
||||||
|
|
||||||
@ -63,6 +62,7 @@ TEMPLATES = [
|
|||||||
'context_processors': [
|
'context_processors': [
|
||||||
'django.template.context_processors.debug',
|
'django.template.context_processors.debug',
|
||||||
'django.template.context_processors.request',
|
'django.template.context_processors.request',
|
||||||
|
'django.template.context_processors.media',
|
||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
],
|
],
|
||||||
@ -121,3 +121,22 @@ USE_TZ = True
|
|||||||
# https://docs.djangoproject.com/en/1.11/howto/static-files/
|
# https://docs.djangoproject.com/en/1.11/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = '/static/'
|
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.urls import path
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from YtManagerApp import views
|
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/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/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/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')
|
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
|
from django.apps import AppConfig
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
class YtManagerAppConfig(AppConfig):
|
class YtManagerAppConfig(AppConfig):
|
||||||
name = 'YtManagerApp'
|
name = 'YtManagerApp'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
from .management import SubscriptionManager
|
# There seems to be a problem related to the auto-reload functionality where ready() is called twice
|
||||||
SubscriptionManager.start_scheduler()
|
# (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 YtManagerApp.models import SubscriptionFolder, Subscription, Video, Channel
|
||||||
from .youtube import YoutubeAPI, YoutubeChannelInfo, YoutubePlaylistItem
|
from YtManagerApp.utils.youtube import YoutubeAPI, YoutubeChannelInfo, YoutubePlaylistItem
|
||||||
|
from django.conf import settings
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
import os
|
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):
|
class FolderManager(object):
|
||||||
|
|
||||||
@ -45,6 +56,18 @@ class FolderManager(object):
|
|||||||
folder = SubscriptionFolder.objects.get(id=fid)
|
folder = SubscriptionFolder.objects.get(id=fid)
|
||||||
folder.delete()
|
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):
|
class SubscriptionManager(object):
|
||||||
__scheduler = BackgroundScheduler()
|
__scheduler = BackgroundScheduler()
|
||||||
@ -53,7 +76,6 @@ class SubscriptionManager(object):
|
|||||||
def create_or_edit(sid, url, name, parent_id):
|
def create_or_edit(sid, url, name, parent_id):
|
||||||
# Create or edit
|
# Create or edit
|
||||||
if sid == '#':
|
if sid == '#':
|
||||||
sub = Subscription()
|
|
||||||
SubscriptionManager.create(url, parent_id, YoutubeAPI.build_public())
|
SubscriptionManager.create(url, parent_id, YoutubeAPI.build_public())
|
||||||
else:
|
else:
|
||||||
sub = Subscription.objects.get(id=int(sid))
|
sub = Subscription.objects.get(id=int(sid))
|
||||||
@ -100,6 +122,11 @@ class SubscriptionManager(object):
|
|||||||
|
|
||||||
sub.save()
|
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
|
@staticmethod
|
||||||
def __get_or_create_channel(url_type, url_id, yt_api: YoutubeAPI):
|
def __get_or_create_channel(url_type, url_id, yt_api: YoutubeAPI):
|
||||||
|
|
||||||
@ -173,12 +200,79 @@ class SubscriptionManager(object):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def __synchronize_all():
|
def __synchronize_all():
|
||||||
print("Running scheduled synchronization... ")
|
print("Running scheduled synchronization... ")
|
||||||
|
|
||||||
|
# Sync subscribed playlists/channels
|
||||||
yt_api = YoutubeAPI.build_public()
|
yt_api = YoutubeAPI.build_public()
|
||||||
for subscription in Subscription.objects.all():
|
for subscription in Subscription.objects.all():
|
||||||
SubscriptionManager.__synchronize(subscription, yt_api)
|
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
|
@staticmethod
|
||||||
def start_scheduler():
|
def start_scheduler():
|
||||||
SubscriptionManager.__scheduler.add_job(SubscriptionManager.__synchronize_all, 'cron',
|
SubscriptionManager.__scheduler.add_job(SubscriptionManager.__synchronize_all, 'cron',
|
||||||
hour='*', minute=44, max_instances=1)
|
hour='*', minute=38, max_instances=1)
|
||||||
SubscriptionManager.__scheduler.start()
|
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.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):
|
class SubscriptionFolder(models.Model):
|
||||||
@ -52,6 +115,13 @@ class Subscription(models.Model):
|
|||||||
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
|
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
|
||||||
icon_default = models.TextField()
|
icon_default = models.TextField()
|
||||||
icon_best = 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):
|
def __str__(self):
|
||||||
return self.name
|
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); }
|
transform: rotate(0deg); }
|
||||||
100% {
|
100% {
|
||||||
transform: rotate(360deg); } }
|
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 */
|
/*# sourceMappingURL=style.css.map */
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": 3,
|
"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"],
|
"sources": ["style.scss"],
|
||||||
"names": [],
|
"names": [],
|
||||||
"file": "style.css"
|
"file": "style.css"
|
||||||
|
@ -35,4 +35,46 @@ $accent-color: #007bff;
|
|||||||
100% {
|
100% {
|
||||||
transform: rotate(360deg);
|
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-toolbar" role="toolbar" aria-label="Subscriptions toolbar">
|
||||||
<div class="btn-group btn-group-sm mr-2" role="group">
|
<div class="btn-group btn-group-sm mr-2" role="group">
|
||||||
<button id="btn_create_sub" type="button" class="btn btn-secondary" >
|
<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>
|
||||||
<button id="btn_create_folder" type="button" class="btn btn-secondary">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm mr-2" role="group">
|
<div class="btn-group btn-group-sm mr-2" role="group">
|
||||||
<button id="btn_edit_node" type="button" class="btn btn-secondary" >
|
<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>
|
||||||
<button id="btn_delete_node" type="button" class="btn btn-secondary" >
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -41,4 +41,10 @@
|
|||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block detail %}
|
||||||
|
<div id="main_detail">
|
||||||
|
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -279,10 +279,10 @@ function tree_Initialize()
|
|||||||
},
|
},
|
||||||
types : {
|
types : {
|
||||||
folder : {
|
folder : {
|
||||||
icon : "material-icons material-folder"
|
icon : "typcn typcn-folder"
|
||||||
},
|
},
|
||||||
sub : {
|
sub : {
|
||||||
icon : "material-icons material-person",
|
icon : "typcn typcn-user",
|
||||||
max_depth : 0
|
max_depth : 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -314,6 +314,13 @@ function tree_ValidateChange(operation, node, parent, position, more)
|
|||||||
function tree_OnSelectionChanged(e, data)
|
function tree_OnSelectionChanged(e, data)
|
||||||
{
|
{
|
||||||
node = data.instance.get_selected(true)[0];
|
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>
|
<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="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' %}">
|
<link rel="stylesheet" href="{% static 'YtManagerApp/css/style.css' %}">
|
||||||
{% block stylesheets %}
|
{% block stylesheets %}
|
||||||
{% endblock %}
|
{% 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({
|
children.append({
|
||||||
"id": "sub" + str(sub.id),
|
"id": "sub" + str(sub.id),
|
||||||
"type": "sub",
|
"type": "sub",
|
||||||
"text": sub.name
|
"text": sub.name,
|
||||||
|
"icon": sub.icon_default
|
||||||
})
|
})
|
||||||
|
|
||||||
return children
|
return children
|
||||||
@ -82,6 +83,21 @@ def ajax_delete_subscription(request: HttpRequest, sid):
|
|||||||
return HttpResponse()
|
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):
|
def index(request: HttpRequest):
|
||||||
context = {}
|
context = {}
|
||||||
return render(request, 'YtManagerApp/index.html', 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=
|
Loading…
Reference in New Issue
Block a user