Finished CRUD operations for subscriptions and folders.

This commit is contained in:
Tiberiu Chibici 2018-10-18 00:38:40 +03:00
parent c3e3bfa33c
commit a6d58dfaa6
20 changed files with 904 additions and 624 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,38 +0,0 @@
from YtManagerApp.models import SubscriptionFolder, Subscription
from typing import Callable, Union, Any, Optional
from django.contrib.auth.models import User
import logging
from django.db.models.functions import Lower
def traverse_tree(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

View File

@ -112,53 +112,3 @@ class SubscriptionManager(object):
sub.save() sub.save()
@staticmethod
def list_videos(fid: int):
sub = Subscription.objects.get(id=fid)
return Video.objects.filter(subscription=sub).order_by('playlist_index')
@staticmethod
def __get_or_create_channel(url_type, url_id, yt_api: YoutubeAPI):
channel: Channel = None
info_channel: YoutubeChannelInfo = None
if url_type == 'user':
channel = Channel.find_by_username(url_id)
if not channel:
info_channel = yt_api.get_channel_info_by_username(url_id)
channel = Channel.find_by_channel_id(info_channel.getId())
elif url_type == 'channel_id':
channel = Channel.find_by_channel_id(url_id)
if not channel:
info_channel = yt_api.get_channel_info(url_id)
elif url_type == 'channel_custom':
channel = Channel.find_by_custom_url(url_id)
if not channel:
found_channel_id = yt_api.search_channel(url_id)
channel = Channel.find_by_channel_id(found_channel_id)
if not channel:
info_channel = yt_api.get_channel_info(found_channel_id)
# Store information about the channel
if info_channel:
if not channel:
channel = Channel()
if url_type == 'user':
channel.username = url_id
SubscriptionManager.__update_channel(channel, info_channel)
return channel
@staticmethod
def __update_channel(channel: Channel, yt_info: YoutubeChannelInfo):
channel.channel_id = yt_info.getId()
channel.custom_url = yt_info.getCustomUrl()
channel.name = yt_info.getTitle()
channel.description = yt_info.getDescription()
channel.icon_default = yt_info.getDefaultThumbnailUrl()
channel.icon_best = yt_info.getBestThumbnailUrl()
channel.upload_playlist_id = yt_info.getUploadsPlaylist()
channel.save()

View File

@ -1,9 +1,8 @@
from YtManagerApp.models import Subscription, Video from YtManagerApp.models import Subscription, Video, SubscriptionFolder
from YtManagerApp.utils.youtube import YoutubePlaylistItem from YtManagerApp.utils.youtube import YoutubePlaylistItem
from typing import Optional from typing import Optional
import re import re
from django.db.models import Q from django.db.models import Q
from YtManagerApp.management.folders import traverse_tree
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -57,7 +56,7 @@ def get_videos(user: User,
if isinstance(node, Subscription): if isinstance(node, Subscription):
return node.id return node.id
return None return None
filter_kwargs['subscription_id__in'] = traverse_tree(folder_id, user, visit) filter_kwargs['subscription_id__in'] = SubscriptionFolder.traverse(folder_id, user, visit)
# Only watched # Only watched
if only_watched is not None: if only_watched is not None:

View File

@ -1,6 +1,11 @@
from django.db import models import logging
from typing import Callable, Union, Any, Optional
from django.contrib.auth.models import User 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 django.db.models.functions import Lower
from YtManagerApp.utils.youtube import YoutubeAPI, YoutubeChannelInfo, YoutubePlaylistInfo
# help_text = user shown text # help_text = user shown text
# verbose_name = user shown name # verbose_name = user shown name
@ -26,7 +31,7 @@ class UserSettings(models.Model):
@staticmethod @staticmethod
def find_by_user(user: User): def find_by_user(user: User):
result = UserSettings.objects.filter2(user=user) result = UserSettings.objects.filter(user=user)
if len(result) > 0: if len(result) > 0:
return result.first() return result.first()
return None return None
@ -74,6 +79,9 @@ class SubscriptionFolder(models.Model):
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True) parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True)
user = models.ForeignKey(User, on_delete=models.CASCADE, null=False, blank=False) user = models.ForeignKey(User, on_delete=models.CASCADE, null=False, blank=False)
class Meta:
ordering = [Lower('parent__name'), Lower('name')]
def __str__(self): def __str__(self):
s = "" s = ""
current = self current = self
@ -82,8 +90,53 @@ class SubscriptionFolder(models.Model):
current = current.parent current = current.parent
return s[:-3] return s[:-3]
class Meta: def delete_folder(self, keep_subscriptions: bool):
ordering = [Lower('parent__name'), Lower('name')] 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 Channel(models.Model): class Channel(models.Model):
@ -96,6 +149,9 @@ class Channel(models.Model):
icon_best = models.TextField() icon_best = models.TextField()
upload_playlist_id = models.TextField() upload_playlist_id = models.TextField()
def __str__(self):
return self.name
@staticmethod @staticmethod
def find_by_channel_id(channel_id): def find_by_channel_id(channel_id):
result = Channel.objects.filter(channel_id=channel_id) result = Channel.objects.filter(channel_id=channel_id)
@ -117,25 +173,95 @@ class Channel(models.Model):
return result.first() return result.first()
return None return None
def __str__(self): def fill(self, yt_channel_info: YoutubeChannelInfo):
return self.name self.channel_id = yt_channel_info.getId()
self.custom_url = yt_channel_info.getCustomUrl()
self.name = yt_channel_info.getTitle()
self.description = yt_channel_info.getDescription()
self.icon_default = yt_channel_info.getDefaultThumbnailUrl()
self.icon_best = yt_channel_info.getBestThumbnailUrl()
self.upload_playlist_id = yt_channel_info.getUploadsPlaylist()
self.save()
@staticmethod
def get_or_create(url_type: str, url_id: str, yt_api: YoutubeAPI):
channel: Channel = None
info_channel: YoutubeChannelInfo = None
if url_type == 'user':
channel = Channel.find_by_username(url_id)
if not channel:
info_channel = yt_api.get_channel_info_by_username(url_id)
channel = Channel.find_by_channel_id(info_channel.getId())
elif url_type == 'channel_id':
channel = Channel.find_by_channel_id(url_id)
if not channel:
info_channel = yt_api.get_channel_info(url_id)
elif url_type == 'channel_custom':
channel = Channel.find_by_custom_url(url_id)
if not channel:
found_channel_id = yt_api.search_channel(url_id)
channel = Channel.find_by_channel_id(found_channel_id)
if not channel:
info_channel = yt_api.get_channel_info(found_channel_id)
# If we downloaded information about the channel, store information
# about the channel here.
if info_channel:
if not channel:
channel = Channel()
if url_type == 'user':
channel.username = url_id
channel.fill(info_channel)
return channel
class Subscription(models.Model): class Subscription(models.Model):
name = models.TextField(null=False) name = models.CharField(null=False, max_length=1024)
parent_folder = models.ForeignKey(SubscriptionFolder, on_delete=models.SET_NULL, null=True, blank=True) parent_folder = models.ForeignKey(SubscriptionFolder, on_delete=models.CASCADE, null=True, blank=True)
playlist_id = models.TextField(null=False, unique=True) playlist_id = models.CharField(null=False, max_length=128)
description = models.TextField() description = models.TextField()
channel = models.ForeignKey(Channel, on_delete=models.CASCADE) channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
icon_default = models.TextField() icon_default = models.CharField(max_length=1024)
icon_best = models.TextField() icon_best = models.CharField(max_length=1024)
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
# overrides # overrides
auto_download = models.BooleanField(null=True) auto_download = models.BooleanField(null=True, blank=True)
download_limit = models.IntegerField(null=True) download_limit = models.IntegerField(null=True, blank=True)
download_order = models.TextField(null=True) download_order = models.CharField(null=True, blank=True, max_length=128)
manager_delete_after_watched = models.BooleanField(null=True) manager_delete_after_watched = models.BooleanField(null=True, blank=True)
def fill_from_playlist(self, info_playlist: YoutubePlaylistInfo):
self.name = info_playlist.getTitle()
self.playlist_id = info_playlist.getId()
self.description = info_playlist.getDescription()
self.icon_default = info_playlist.getDefaultThumbnailUrl()
self.icon_best = info_playlist.getBestThumbnailUrl()
def copy_from_channel(self):
# No point in storing info about the 'uploads from X' playlist
self.name = self.channel.name
self.playlist_id = self.channel.upload_playlist_id
self.description = self.channel.description
self.icon_default = self.channel.icon_default
self.icon_best = self.channel.icon_best
def fetch_from_url(self, url, yt_api: YoutubeAPI):
url_type, url_id = yt_api.parse_channel_url(url)
if url_type == 'playlist_id':
info_playlist = yt_api.get_playlist_info(url_id)
self.channel = Channel.get_or_create('channel_id', info_playlist.getChannelId(), yt_api)
self.fill_from_playlist(info_playlist)
else:
self.channel = Channel.get_or_create(url_type, url_id, yt_api)
self.copy_from_channel()
def delete_subscription(self, keep_downloaded_videos: bool):
self.delete()
def __str__(self): def __str__(self):
return self.name return self.name

View File

@ -1,16 +1,39 @@
/* Some material font helpers */
.material-folder::before {
content: "\e2c7"; }
.material-person::before {
content: "\e7fd"; }
/* Loading animation */ /* Loading animation */
.loading-dual-ring { .loading-dual-ring {
display: inline-block; display: inline-block;
width: 64px; width: 64px;
height: 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 { .loading-dual-ring-center-screen {
position: fixed; position: fixed;
top: 50%; top: 50%;
@ -18,17 +41,6 @@
margin-top: -32px; margin-top: -32px;
margin-left: -32px; } margin-left: -32px; }
.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; }
.black-overlay { .black-overlay {
position: fixed; position: fixed;
/* Sit on top of the page content */ /* Sit on top of the page content */
@ -49,11 +61,6 @@
cursor: pointer; cursor: pointer;
/* Add a pointer on hover */ } /* Add a pointer on hover */ }
@keyframes loading-dual-ring {
0% {
transform: rotate(0deg); }
100% {
transform: rotate(360deg); } }
.video-gallery .card-wrapper { .video-gallery .card-wrapper {
padding: 0.4rem; padding: 0.4rem;
margin-bottom: .5rem; } margin-bottom: .5rem; }

View File

@ -1,6 +1,6 @@
{ {
"version": 3, "version": 3,
"mappings": "AAEA,gCAAgC;AAChC,wBAAyB;EACrB,OAAO,EAAE,OAAO;;AAGpB,wBAAyB;EACrB,OAAO,EAAE,OAAO;;AAGpB,uBAAuB;AACvB,kBAAmB;EACf,OAAO,EAAE,YAAY;EACrB,KAAK,EAAE,IAAI;EACX,MAAM,EAAE,IAAI;;AAGhB,gCAAiC;EAC7B,QAAQ,EAAE,KAAK;EACf,GAAG,EAAE,GAAG;EACR,IAAI,EAAE,GAAG;EACT,UAAU,EAAE,KAAK;EACjB,WAAW,EAAE,KAAK;;AAGtB,wBAAyB;EACrB,OAAO,EAAE,GAAG;EACZ,OAAO,EAAE,KAAK;EACd,KAAK,EAAE,IAAI;EACX,MAAM,EAAE,IAAI;EACZ,MAAM,EAAE,GAAG;EACX,aAAa,EAAE,GAAG;EAClB,MAAM,EAAE,iBAAuB;EAC/B,YAAY,EAAE,uCAAmD;EACjE,SAAS,EAAE,sCAAsC;;AAGrD,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;;AAGjD,4BAOC;EANG,EAAG;IACC,SAAS,EAAE,YAAY;EAE3B,IAAK;IACD,SAAS,EAAE,cAAc;AAK7B,4BAAc;EACV,OAAO,EAAE,MAAM;EACf,aAAa,EAAE,KAAK;AAGpB,+BAAW;EACP,OAAO,EAAE,MAAM;AAEnB,+BAAW;EACP,SAAS,EAAE,IAAI;EACf,aAAa,EAAE,KAAK;AAExB,gCAAY;EACR,SAAS,EAAE,IAAI;EACf,aAAa,EAAE,KAAK;EAEpB,uCAAO;IACH,SAAS,EAAE,GAAG;AAGtB,iCAAa;EACT,OAAO,EAAE,YAAY;AAGzB,+BAAW;EACP,YAAY,EAAE,QAAQ;EACtB,qCAAQ;IACJ,eAAe,EAAE,IAAI;AAKjC,8BAAgB;EACZ,KAAK,EAAE,OAAO;AAElB,6BAAe;EACX,KAAK,EAAE,OAAO;;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", "mappings": "AAoBA,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;AASjC,8BAAgB;EACZ,KAAK,EAAE,OAAO;AAElB,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",
"sources": ["style.scss"], "sources": ["style.scss"],
"names": [], "names": [],
"file": "style.css" "file": "style.css"

View File

@ -1,19 +1,39 @@
$accent-color: #007bff; $accent-color: #007bff;
/* Some material font helpers */ @mixin loading-dual-ring($scale : 1) {
.material-folder::before { display: inline-block;
content: "\e2c7"; width: $scale * 64px;
} height: $scale * 64px;
.material-person::before { &:after {
content: "\e7fd"; 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 animation */
.loading-dual-ring { .loading-dual-ring {
display: inline-block; @include loading-dual-ring(1.0);
width: 64px; }
height: 64px;
.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 { .loading-dual-ring-center-screen {
@ -24,18 +44,6 @@ $accent-color: #007bff;
margin-left: -32px; margin-left: -32px;
} }
.loading-dual-ring:after {
content: " ";
display: block;
width: 46px;
height: 46px;
margin: 1px;
border-radius: 50%;
border: 5px solid $accent-color;
border-color: $accent-color transparent $accent-color transparent;
animation: loading-dual-ring 1.2s linear infinite;
}
.black-overlay { .black-overlay {
position: fixed; /* Sit on top of the page content */ position: fixed; /* Sit on top of the page content */
display: none; /* Hidden by default */ display: none; /* Hidden by default */
@ -50,15 +58,6 @@ $accent-color: #007bff;
cursor: pointer; /* Add a pointer on hover */ cursor: pointer; /* Add a pointer on hover */
} }
@keyframes loading-dual-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.video-gallery { .video-gallery {
.card-wrapper { .card-wrapper {
padding: 0.4rem; padding: 0.4rem;
@ -90,6 +89,10 @@ $accent-color: #007bff;
text-decoration: none; text-decoration: none;
} }
} }
.card-img-top {
}
} }
.video-icon-yes { .video-icon-yes {

View File

@ -6,16 +6,18 @@
{% endblock modal_title %} {% endblock modal_title %}
{% block modal_content %} {% block modal_content %}
<form action="{% url 'modal_delete_folder' form.instance.pk %}" method="post"> <form action="{% url 'modal_delete_folder' object.id %}" method="post">
{% csrf_token %}
{{ block.super }} {{ block.super }}
</form> </form>
{% endblock %} {% endblock %}
{% block modal_body %} {% block modal_body %}
{% crispy form %} <p>Are you sure you want to delete folder &quot;{{ object }}&quot; and all its subfolders?</p>
{{ form | crispy }}
{% endblock modal_body %} {% endblock modal_body %}
{% block modal_footer %} {% block modal_footer %}
<input class="btn btn-danger" type="submit" value="Save" aria-label="Delete"> <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"> <input class="btn btn-secondary" type="button" value="Cancel" data-dismiss="modal" aria-label="Cancel">
{% endblock modal_footer %} {% endblock modal_footer %}

View File

@ -28,6 +28,7 @@
{% block modal_footer_wrapper %} {% block modal_footer_wrapper %}
<div class="modal-footer"> <div class="modal-footer">
<div id="modal-loading-ring" class="loading-dual-ring-small mr-auto" style="display: none;"></div>
{% block modal_footer %} {% block modal_footer %}
{% endblock modal_footer %} {% endblock modal_footer %}
</div> </div>

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

@ -1,45 +0,0 @@
<div id="subscriptionEditDialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 id="subscriptionEditDialog_Title" class="modal-title">Edit subscription</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div id="subscriptionEditDialog_Loading" class="modal-body">
<div class="loading-dual-ring"></div>
</div>
<div id="subscriptionEditDialog_Error"></div>
<form id="subscriptionEditDialog_Form" action="{% url 'ajax_edit_subscription' %}" method="post">
<div class="modal-body">
{% csrf_token %}
<input type="hidden" id="subscriptionEditDialog_Id" name="id" value="#">
<div class="form-group row">
<label class="col-sm-3" for="subscriptionEditDialog_Url">Link:</label>
<div class="col-sm-9">
<input type="text" class="form-control" id="subscriptionEditDialog_Url" name="url" placeholder="Subscription URL (playlist, channel)">
</div>
</div>
<div class="form-group row">
<label class="col-sm-3" for="subscriptionEditDialog_Name">Name:</label>
<div class="col-sm-9">
<input type="text" class="form-control" id="subscriptionEditDialog_Name" name="name" placeholder="Subscription name">
</div>
</div>
<div class="form-group row">
<label class="col-sm-3" for="subscriptionEditDialog_Parent">Parent subscription</label>
<div class="col-sm-9">
<select class="form-control" id="subscriptionEditDialog_Parent" name="parent">
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button id="subscriptionEditDialog_Submit" type="submit" class="btn btn-primary">Submit</button>
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
</div>
</form>
</div>
</div>
</div>

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

@ -55,6 +55,7 @@ class AjaxModal
this.modal = null; this.modal = null;
this.form = null; this.form = null;
this.submitCallback = null; this.submitCallback = null;
this.modalLoadingRing = null;
} }
setSubmitCallback(callback) { setSubmitCallback(callback) {
@ -84,6 +85,7 @@ class AjaxModal
this.modal = this.wrapper.find('.modal'); this.modal = this.wrapper.find('.modal');
this.form = this.wrapper.find('form'); this.form = this.wrapper.find('form');
this.modalLoadingRing = this.wrapper.find('#modal-loading-ring');
let pThis = this; let pThis = this;
this.form.submit(function(e) { this.form.submit(function(e) {
@ -104,8 +106,15 @@ class AjaxModal
}) })
.fail(function() { .fail(function() {
pThis._submitFailed(); 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(); e.preventDefault();
} }

View File

@ -236,11 +236,10 @@ function treeNode_Edit()
modal.loadAndShow(); modal.loadAndShow();
} }
else { else {
//TODO: let id = node.id.replace('sub', '');
//let id = node.id.replace('sub', ''); let modal = new AjaxModal("{% url 'modal_update_subscription' 98765 %}".replace('98765', id));
//let modal = new AjaxModal("{ url 'modal_update_subscription' 98765 }".replace('98765', id)); modal.setSubmitCallback(tree_Refresh);
//modal.setSubmitCallback(tree_Refresh); modal.loadAndShow();
//modal.loadAndShow();
} }
} }
} }
@ -259,11 +258,10 @@ function treeNode_Delete()
modal.loadAndShow(); modal.loadAndShow();
} }
else { else {
//TODO: let id = node.id.replace('sub', '');
//let id = node.id.replace('sub', ''); let modal = new AjaxModal("{% url 'modal_delete_subscription' 98765 %}".replace('98765', id));
//let modal = new AjaxModal("{ url 'modal_delete_subscription' 98765 }".replace('98765', id)); modal.setSubmitCallback(tree_Refresh);
//modal.setSubmitCallback(tree_Refresh); modal.loadAndShow();
//modal.loadAndShow();
} }
} }
} }
@ -274,7 +272,7 @@ function tree_Initialize()
treeWrapper.jstree({ treeWrapper.jstree({
core : { core : {
data : { data : {
url : "{% url 'ajax_index_get_tree' %}" url : "{% url 'ajax_get_tree' %}"
}, },
check_callback : tree_ValidateChange, check_callback : tree_ValidateChange,
themes : { themes : {
@ -350,7 +348,7 @@ function videos_Reload()
loadingDiv.fadeIn(300); loadingDiv.fadeIn(300);
// Perform query // Perform query
$.post("{% url 'ajax_index_get_videos' %}", filterForm.serialize()) $.post("{% url 'ajax_get_videos' %}", filterForm.serialize())
.done(function (result) { .done(function (result) {
$("#videos-wrapper").html(result); $("#videos-wrapper").html(result);
}) })
@ -393,6 +391,11 @@ $(document).ready(function ()
// folderEditDialog = new FolderEditDialog('#folderEditDialog'); // folderEditDialog = new FolderEditDialog('#folderEditDialog');
// subscriptionEditDialog = new SubscriptionEditDialog('#subscriptionEditDialog'); // subscriptionEditDialog = new SubscriptionEditDialog('#subscriptionEditDialog');
// //
$("#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 () { $("#btn_create_folder").on("click", function () {
let modal = new AjaxModal("{% url 'modal_create_folder' %}"); let modal = new AjaxModal("{% url 'modal_create_folder' %}");
modal.setSubmitCallback(tree_Refresh); modal.setSubmitCallback(tree_Refresh);

View File

@ -19,7 +19,8 @@ from django.conf.urls.static import static
from django.urls import path from django.urls import path
from .views.auth import ExtendedLoginView, RegisterView, RegisterDoneView from .views.auth import ExtendedLoginView, RegisterView, RegisterDoneView
from .views.index import index, ajax_get_tree, ajax_get_videos, CreateFolderModal, UpdateFolderModal, DeleteFolderModal from .views.index import index, ajax_get_tree, ajax_get_videos, CreateFolderModal, UpdateFolderModal, DeleteFolderModal,\
CreateSubscriptionModal, UpdateSubscriptionModal, DeleteSubscriptionModal
from .views import old_views from .views import old_views
urlpatterns = [ urlpatterns = [
@ -30,8 +31,8 @@ urlpatterns = [
path('', include('django.contrib.auth.urls')), path('', include('django.contrib.auth.urls')),
# Ajax # Ajax
path('ajax/index_get_tree/', ajax_get_tree, name='ajax_index_get_tree'), path('ajax/get_tree/', ajax_get_tree, name='ajax_get_tree'),
path('ajax/index_get_videos/', ajax_get_videos, name='ajax_index_get_videos'), path('ajax/get_videos/', ajax_get_videos, name='ajax_get_videos'),
# Modals # Modals
path('modal/create_folder/', CreateFolderModal.as_view(), name='modal_create_folder'), path('modal/create_folder/', CreateFolderModal.as_view(), name='modal_create_folder'),
@ -39,6 +40,11 @@ urlpatterns = [
path('modal/update_folder/<int:pk>/', UpdateFolderModal.as_view(), name='modal_update_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/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'),
# Index # Index
path('', index, name='home'), path('', index, name='home'),

View File

@ -1,4 +1,5 @@
from googleapiclient.discovery import build from googleapiclient.discovery import build
from googleapiclient.errors import Error as APIError
from google_auth_oauthlib.flow import InstalledAppFlow from google_auth_oauthlib.flow import InstalledAppFlow
from django.conf import settings from django.conf import settings
import re import re
@ -7,6 +8,26 @@ API_SERVICE_NAME = 'youtube'
API_VERSION = 'v3' API_VERSION = 'v3'
class YoutubeException(Exception):
pass
class YoutubeInvalidURLException(YoutubeException):
pass
class YoutubeChannelNotFoundException(YoutubeException):
pass
class YoutubeUserNotFoundException(YoutubeException):
pass
class YoutubePlaylistNotFoundException(YoutubeException):
pass
class YoutubeChannelInfo(object): class YoutubeChannelInfo(object):
def __init__(self, result_dict): def __init__(self, result_dict):
self.__id = result_dict['id'] self.__id = result_dict['id']
@ -23,7 +44,10 @@ class YoutubeChannelInfo(object):
return self.__snippet['description'] return self.__snippet['description']
def getCustomUrl(self): def getCustomUrl(self):
return self.__snippet['customUrl'] try:
return self.__snippet['customUrl']
except KeyError:
return None
def getDefaultThumbnailUrl(self): def getDefaultThumbnailUrl(self):
return self.__snippet['thumbnails']['default']['url'] return self.__snippet['thumbnails']['default']['url']
@ -142,7 +166,7 @@ class YoutubeAPI(object):
if match: if match:
return 'channel_custom', match.group(1) return 'channel_custom', match.group(1)
raise Exception('Unrecognized URL format!') raise YoutubeInvalidURLException('Unrecognized URL format!')
def get_playlist_info(self, list_id) -> YoutubePlaylistInfo: def get_playlist_info(self, list_id) -> YoutubePlaylistInfo:
result = self.service.playlists()\ result = self.service.playlists()\
@ -150,7 +174,7 @@ class YoutubeAPI(object):
.execute() .execute()
if len(result['items']) <= 0: if len(result['items']) <= 0:
raise Exception("Invalid playlist ID.") raise YoutubePlaylistNotFoundException("Invalid playlist ID.")
return YoutubePlaylistInfo(result['items'][0]) return YoutubePlaylistInfo(result['items'][0])
@ -160,7 +184,7 @@ class YoutubeAPI(object):
.execute() .execute()
if len(result['items']) <= 0: if len(result['items']) <= 0:
raise Exception('Invalid user.') raise YoutubeUserNotFoundException('Invalid user.')
return YoutubeChannelInfo(result['items'][0]) return YoutubeChannelInfo(result['items'][0])
@ -170,7 +194,7 @@ class YoutubeAPI(object):
.execute() .execute()
if len(result['items']) <= 0: if len(result['items']) <= 0:
raise Exception('Invalid channel ID.') raise YoutubeChannelNotFoundException('Invalid channel ID.')
return YoutubeChannelInfo(result['items'][0]) return YoutubeChannelInfo(result['items'][0])
@ -180,7 +204,7 @@ class YoutubeAPI(object):
.execute() .execute()
if len(result['items']) <= 0: if len(result['items']) <= 0:
raise Exception('Could not find channel!') raise YoutubeChannelNotFoundException('Could not find channel!')
channel_result = result['items'][0] channel_result = result['items'][0]
return channel_result['id']['channelId'] return channel_result['id']['channelId']

View File

@ -35,10 +35,12 @@ class ModalMixin(ContextMixin):
return data return data
def modal_response(self, form, success=True): def modal_response(self, form, success=True, error_msg=None):
result = {'success': success} result = {'success': success}
if not success: if not success:
result['errors'] = form.errors.get_json_data(escape_html=True) 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) return JsonResponse(result)

View File

@ -2,15 +2,14 @@ from django.http import HttpRequest, HttpResponseBadRequest, JsonResponse
from django.shortcuts import render from django.shortcuts import render
from django import forms from django import forms
from django.views.generic import CreateView, UpdateView, DeleteView from django.views.generic import CreateView, UpdateView, DeleteView
from YtManagerApp.management.folders import traverse_tree from django.views.generic.edit import FormMixin
from YtManagerApp.management.videos import get_videos from YtManagerApp.management.videos import get_videos
from YtManagerApp.models import Subscription, SubscriptionFolder from YtManagerApp.models import Subscription, SubscriptionFolder
from YtManagerApp.views.controls.modal import ModalMixin from YtManagerApp.views.controls.modal import ModalMixin
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field from crispy_forms.layout import Layout, Field, Div, HTML
from crispy_forms.layout import Submit
from django.db.models import Q from django.db.models import Q
from YtManagerApp.utils import youtube
class VideoFilterForm(forms.Form): class VideoFilterForm(forms.Form):
CHOICES_SORT = ( CHOICES_SORT = (
@ -69,7 +68,7 @@ class VideoFilterForm(forms.Form):
self.helper.form_id = 'form_video_filter' self.helper.form_id = 'form_video_filter'
self.helper.form_class = 'form-inline' self.helper.form_class = 'form-inline'
self.helper.form_method = 'POST' self.helper.form_method = 'POST'
self.helper.form_action = 'ajax_index_get_videos' self.helper.form_action = 'ajax_get_videos'
self.helper.field_class = 'mr-1' self.helper.field_class = 'mr-1'
self.helper.label_class = 'ml-2 mr-1 no-asterisk' self.helper.label_class = 'ml-2 mr-1 no-asterisk'
@ -104,7 +103,7 @@ def __tree_folder_id(fd_id):
def __tree_sub_id(sub_id): def __tree_sub_id(sub_id):
if sub_id is None: if sub_id is None:
return '#' return '#'
return 'folder' + str(sub_id) return 'sub' + str(sub_id)
def index(request: HttpRequest): def index(request: HttpRequest):
@ -137,7 +136,7 @@ def ajax_get_tree(request: HttpRequest):
"parent": __tree_folder_id(node.parent_folder_id) "parent": __tree_folder_id(node.parent_folder_id)
} }
result = traverse_tree(None, request.user, visit) result = SubscriptionFolder.traverse(None, request.user, visit)
return JsonResponse(result, safe=False) return JsonResponse(result, safe=False)
@ -220,6 +219,120 @@ class UpdateFolderModal(ModalMixin, UpdateView):
form_class = SubscriptionFolderForm form_class = SubscriptionFolderForm
class DeleteFolderModal(ModalMixin, DeleteView): class DeleteFolderForm(forms.Form):
keep_subscriptions = forms.BooleanField(required=False, initial=False, label="Keep subscriptions")
class DeleteFolderModal(ModalMixin, FormMixin, DeleteView):
template_name = 'YtManagerApp/controls/folder_delete_modal.html' template_name = 'YtManagerApp/controls/folder_delete_modal.html'
model = SubscriptionFolder 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']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
'playlist_url',
'parent_folder'
)
def clean_playlist_url(self):
playlist_url = self.cleaned_data['playlist_url']
try:
youtube.YoutubeAPI.parse_channel_url(playlist_url)
except youtube.YoutubeInvalidURLException:
raise forms.ValidationError('Invalid playlist/channel URL, or not in a recognized format.')
return playlist_url
class CreateSubscriptionModal(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.YoutubeChannelNotFoundException:
return self.modal_response(form, False, 'Could not find a channel based on the given URL. Please verify that the URL is correct.')
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', 'manager_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',
'manager_delete_after_watched'
)
class UpdateSubscriptionModal(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(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)