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()
@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 typing import Optional
import re
from django.db.models import Q
from YtManagerApp.management.folders import traverse_tree
from django.contrib.auth.models import User
@ -57,7 +56,7 @@ def get_videos(user: User,
if isinstance(node, Subscription):
return node.id
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
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.db import models
from django.db.models.functions import Lower
from YtManagerApp.utils.youtube import YoutubeAPI, YoutubeChannelInfo, YoutubePlaylistInfo
# help_text = user shown text
# verbose_name = user shown name
@ -26,7 +31,7 @@ class UserSettings(models.Model):
@staticmethod
def find_by_user(user: User):
result = UserSettings.objects.filter2(user=user)
result = UserSettings.objects.filter(user=user)
if len(result) > 0:
return result.first()
return None
@ -74,6 +79,9 @@ class SubscriptionFolder(models.Model):
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
@ -82,8 +90,53 @@ class SubscriptionFolder(models.Model):
current = current.parent
return s[:-3]
class Meta:
ordering = [Lower('parent__name'), Lower('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 Channel(models.Model):
@ -96,6 +149,9 @@ class Channel(models.Model):
icon_best = models.TextField()
upload_playlist_id = models.TextField()
def __str__(self):
return self.name
@staticmethod
def find_by_channel_id(channel_id):
result = Channel.objects.filter(channel_id=channel_id)
@ -117,25 +173,95 @@ class Channel(models.Model):
return result.first()
return None
def __str__(self):
return self.name
def fill(self, yt_channel_info: YoutubeChannelInfo):
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):
name = models.TextField(null=False)
parent_folder = models.ForeignKey(SubscriptionFolder, on_delete=models.SET_NULL, null=True, blank=True)
playlist_id = models.TextField(null=False, unique=True)
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 = models.ForeignKey(Channel, on_delete=models.CASCADE)
icon_default = models.TextField()
icon_best = models.TextField()
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)
download_limit = models.IntegerField(null=True)
download_order = models.TextField(null=True)
manager_delete_after_watched = models.BooleanField(null=True)
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)
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):
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-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%;
@ -18,17 +41,6 @@
margin-top: -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 {
position: fixed;
/* Sit on top of the page content */
@ -49,11 +61,6 @@
cursor: pointer;
/* Add a pointer on hover */ }
@keyframes loading-dual-ring {
0% {
transform: rotate(0deg); }
100% {
transform: rotate(360deg); } }
.video-gallery .card-wrapper {
padding: 0.4rem;
margin-bottom: .5rem; }

View File

@ -1,6 +1,6 @@
{
"version": 3,
"mappings": "AAEA,gCAAgC;AAChC,wBAAyB;EACrB,OAAO,EAAE,OAAO;;AAGpB,wBAAyB;EACrB,OAAO,EAAE,OAAO;;AAGpB,uBAAuB;AACvB,kBAAmB;EACf,OAAO,EAAE,YAAY;EACrB,KAAK,EAAE,IAAI;EACX,MAAM,EAAE,IAAI;;AAGhB,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"],
"names": [],
"file": "style.css"

View File

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

View File

@ -6,16 +6,18 @@
{% endblock modal_title %}
{% 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 }}
</form>
{% endblock %}
{% 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 %}
{% 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">
{% endblock modal_footer %}

View File

@ -28,6 +28,7 @@
{% 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>

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.form = null;
this.submitCallback = null;
this.modalLoadingRing = null;
}
setSubmitCallback(callback) {
@ -84,6 +85,7 @@ class AjaxModal
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) {
@ -104,8 +106,15 @@ class AjaxModal
})
.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();
}

View File

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

View File

@ -19,7 +19,8 @@ from django.conf.urls.static import static
from django.urls import path
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
urlpatterns = [
@ -30,8 +31,8 @@ urlpatterns = [
path('', include('django.contrib.auth.urls')),
# Ajax
path('ajax/index_get_tree/', ajax_get_tree, name='ajax_index_get_tree'),
path('ajax/index_get_videos/', ajax_get_videos, name='ajax_index_get_videos'),
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'),
@ -39,6 +40,11 @@ urlpatterns = [
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'),
# Index
path('', index, name='home'),

View File

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

View File

@ -35,10 +35,12 @@ class ModalMixin(ContextMixin):
return data
def modal_response(self, form, success=True):
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)

View File

@ -2,15 +2,14 @@ from django.http import HttpRequest, HttpResponseBadRequest, JsonResponse
from django.shortcuts import render
from django import forms
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.models import Subscription, SubscriptionFolder
from YtManagerApp.views.controls.modal import ModalMixin
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field
from crispy_forms.layout import Submit
from crispy_forms.layout import Layout, Field, Div, HTML
from django.db.models import Q
from YtManagerApp.utils import youtube
class VideoFilterForm(forms.Form):
CHOICES_SORT = (
@ -69,7 +68,7 @@ class VideoFilterForm(forms.Form):
self.helper.form_id = 'form_video_filter'
self.helper.form_class = 'form-inline'
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.label_class = 'ml-2 mr-1 no-asterisk'
@ -104,7 +103,7 @@ def __tree_folder_id(fd_id):
def __tree_sub_id(sub_id):
if sub_id is None:
return '#'
return 'folder' + str(sub_id)
return 'sub' + str(sub_id)
def index(request: HttpRequest):
@ -137,7 +136,7 @@ def ajax_get_tree(request: HttpRequest):
"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)
@ -220,6 +219,120 @@ class UpdateFolderModal(ModalMixin, UpdateView):
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'
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)