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