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
|
||||
.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=
|
Loading…
Reference in New Issue
Block a user