Added docker support

This commit is contained in:
Jett Jackson
2018-10-30 14:15:49 +08:00
parent 97e7e792f8
commit 84b0c2e861
108 changed files with 240 additions and 16 deletions

View File

View 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)

View 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)

View 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
View 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()

View File

View 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

View 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)

View 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)

View 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)

View 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)

View 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),
),
]

View 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,
),
]

View 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),
),
]

View 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),
),
]

View 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),
),
]

View 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',
),
]

View 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',
),
]

View File

385
app/YtManagerApp/models.py Normal file
View 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}"'

View 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()

View File

@ -0,0 +1,9 @@
.login-card {
width: 26rem;
margin: 2rem 0; }
.register-card {
max-width: 35rem;
margin: 2rem 0; }
/*# sourceMappingURL=login.css.map */

View 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"
}

View File

@ -0,0 +1,9 @@
.login-card {
max-width: 26rem;
margin: 2rem 0;
}
.register-card {
max-width: 35rem;
margin: 2rem 0;
}

View 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 */

View 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"
}

View 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;
}

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,92 @@
Copyright (c) 2014, Stephen Hutchings (http://www.s-ings.com/).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
SIL OPEN FONT LICENSE
Version 1.1 - 26 February 2007
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting — in part or in whole — any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 182 KiB

View File

@ -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 %}

View File

@ -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 &quot;{{ object }}&quot; 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 %}

View File

@ -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 %}

View 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">&times;</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 %}

View File

@ -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 %}

View File

@ -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 &quot;{{ object }}&quot;?</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 %}

View File

@ -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 %}

View 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 %}

View File

@ -0,0 +1,9 @@
{% extends "YtManagerApp/master_default.html" %}
{% load static %}
{% block body %}
<h1>Hello</h1>
<h2>Please log in to continue</h2>
{% endblock %}

View 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>&#x2022;</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>

View 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);
});

View 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();
});

View 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>

View 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 %}

View 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 %}

View 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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View 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 %}

View 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 %}

View 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])

View 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])

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

60
app/YtManagerApp/urls.py Normal file
View 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)

View File

View 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,))

View 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)

View File

View 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
})

View 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'

View 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)

View 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)

View 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