diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 4009c67..dd56b21 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -2,22 +2,24 @@ - - - - + + + - + + + - - - + + + + @@ -29,7 +31,7 @@ - + @@ -71,14 +73,14 @@ - + - + @@ -88,26 +90,26 @@ - + - + - + - + - + - + - - + + @@ -117,15 +119,15 @@ - + - + - + - + - + @@ -133,86 +135,52 @@ - - + + - + - + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + - + - - + + + + + + + + + + + + + + + + @@ -220,8 +188,8 @@ - - + + @@ -232,17 +200,32 @@ - - + + - - + + - - + + + + + + + + + + + + + + + + + @@ -250,8 +233,56 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -265,17 +296,14 @@ - folder - modal - folderEditDialog subscriptionEditDialog_ folder_edit_dialog folderEditDialog_ @@ -303,6 +331,9 @@ modal_edit_folder modal_update_folder modal_delete_folder + ajax_index_get_videos + ajax_index_get_tree + self.helper loading @@ -315,6 +346,8 @@ modal_ videos-wrapper videos-loading + ajax_get_videos + ajax_get_tree @@ -323,16 +356,6 @@ @@ -389,10 +422,10 @@ - @@ -496,14 +529,6 @@ - - - - - - - - @@ -525,6 +550,14 @@ + + + + + + + + @@ -556,11 +589,6 @@ - - - - - @@ -568,6 +596,11 @@ + + + + + - + - + - + - + - + @@ -725,7 +758,7 @@ - + @@ -771,90 +804,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -921,13 +870,6 @@ - - - - - - - @@ -955,36 +897,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1000,13 +912,6 @@ - - - - - - - @@ -1014,48 +919,10 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1066,91 +933,17 @@ - + - - - - - - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + @@ -1161,5 +954,265 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/YtManagerApp/management/folders.py b/YtManagerApp/management/folders.py deleted file mode 100644 index a5af081..0000000 --- a/YtManagerApp/management/folders.py +++ /dev/null @@ -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 diff --git a/YtManagerApp/management/management.py b/YtManagerApp/management/management.py index 7533f62..6862518 100644 --- a/YtManagerApp/management/management.py +++ b/YtManagerApp/management/management.py @@ -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() diff --git a/YtManagerApp/management/videos.py b/YtManagerApp/management/videos.py index 9dce364..303c0cf 100644 --- a/YtManagerApp/management/videos.py +++ b/YtManagerApp/management/videos.py @@ -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: diff --git a/YtManagerApp/models.py b/YtManagerApp/models.py index 81cc1dc..2939b1a 100644 --- a/YtManagerApp/models.py +++ b/YtManagerApp/models.py @@ -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 diff --git a/YtManagerApp/static/YtManagerApp/css/style.css b/YtManagerApp/static/YtManagerApp/css/style.css index f95552a..74e3624 100644 --- a/YtManagerApp/static/YtManagerApp/css/style.css +++ b/YtManagerApp/static/YtManagerApp/css/style.css @@ -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; } diff --git a/YtManagerApp/static/YtManagerApp/css/style.css.map b/YtManagerApp/static/YtManagerApp/css/style.css.map index 8a24164..f183181 100644 --- a/YtManagerApp/static/YtManagerApp/css/style.css.map +++ b/YtManagerApp/static/YtManagerApp/css/style.css.map @@ -1,6 +1,6 @@ { "version": 3, -"mappings": "AAEA,gCAAgC;AAChC,wBAAyB;EACrB,OAAO,EAAE,OAAO;;AAGpB,wBAAyB;EACrB,OAAO,EAAE,OAAO;;AAGpB,uBAAuB;AACvB,kBAAmB;EACf,OAAO,EAAE,YAAY;EACrB,KAAK,EAAE,IAAI;EACX,MAAM,EAAE,IAAI;;AAGhB,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" diff --git a/YtManagerApp/static/YtManagerApp/css/style.scss b/YtManagerApp/static/YtManagerApp/css/style.scss index ef655dd..7166be0 100644 --- a/YtManagerApp/static/YtManagerApp/css/style.scss +++ b/YtManagerApp/static/YtManagerApp/css/style.scss @@ -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 { diff --git a/YtManagerApp/templates/YtManagerApp/controls/folder_delete_modal.html b/YtManagerApp/templates/YtManagerApp/controls/folder_delete_modal.html index faeca72..8b52457 100644 --- a/YtManagerApp/templates/YtManagerApp/controls/folder_delete_modal.html +++ b/YtManagerApp/templates/YtManagerApp/controls/folder_delete_modal.html @@ -6,16 +6,18 @@ {% endblock modal_title %} {% block modal_content %} -
+ + {% csrf_token %} {{ block.super }}
{% endblock %} {% block modal_body %} - {% crispy form %} +

Are you sure you want to delete folder "{{ object }}" and all its subfolders?

+ {{ form | crispy }} {% endblock modal_body %} {% block modal_footer %} - + {% endblock modal_footer %} \ No newline at end of file diff --git a/YtManagerApp/templates/YtManagerApp/controls/modal.html b/YtManagerApp/templates/YtManagerApp/controls/modal.html index a4948fd..51677f2 100644 --- a/YtManagerApp/templates/YtManagerApp/controls/modal.html +++ b/YtManagerApp/templates/YtManagerApp/controls/modal.html @@ -28,6 +28,7 @@ {% block modal_footer_wrapper %} diff --git a/YtManagerApp/templates/YtManagerApp/controls/subscription_create_modal.html b/YtManagerApp/templates/YtManagerApp/controls/subscription_create_modal.html new file mode 100644 index 0000000..f6ce237 --- /dev/null +++ b/YtManagerApp/templates/YtManagerApp/controls/subscription_create_modal.html @@ -0,0 +1,21 @@ +{% extends 'YtManagerApp/controls/modal.html' %} +{% load crispy_forms_tags %} + +{% block modal_title %} + New subscription +{% endblock modal_title %} + +{% block modal_content %} +
+ {{ block.super }} +
+{% endblock %} + +{% block modal_body %} + {% crispy form %} +{% endblock modal_body %} + +{% block modal_footer %} + + +{% endblock modal_footer %} \ No newline at end of file diff --git a/YtManagerApp/templates/YtManagerApp/controls/subscription_delete_modal.html b/YtManagerApp/templates/YtManagerApp/controls/subscription_delete_modal.html new file mode 100644 index 0000000..1765894 --- /dev/null +++ b/YtManagerApp/templates/YtManagerApp/controls/subscription_delete_modal.html @@ -0,0 +1,23 @@ +{% extends 'YtManagerApp/controls/modal.html' %} +{% load crispy_forms_tags %} + +{% block modal_title %} + Delete subscription +{% endblock modal_title %} + +{% block modal_content %} +
+ {% csrf_token %} + {{ block.super }} +
+{% endblock %} + +{% block modal_body %} +

Are you sure you want to delete subscription "{{ object }}"?

+ {{ form | crispy }} +{% endblock modal_body %} + +{% block modal_footer %} + + +{% endblock modal_footer %} \ No newline at end of file diff --git a/YtManagerApp/templates/YtManagerApp/controls/subscription_edit_dialog.html b/YtManagerApp/templates/YtManagerApp/controls/subscription_edit_dialog.html deleted file mode 100644 index 2de4513..0000000 --- a/YtManagerApp/templates/YtManagerApp/controls/subscription_edit_dialog.html +++ /dev/null @@ -1,45 +0,0 @@ - \ No newline at end of file diff --git a/YtManagerApp/templates/YtManagerApp/controls/subscription_update_modal.html b/YtManagerApp/templates/YtManagerApp/controls/subscription_update_modal.html new file mode 100644 index 0000000..6cecc2e --- /dev/null +++ b/YtManagerApp/templates/YtManagerApp/controls/subscription_update_modal.html @@ -0,0 +1,21 @@ +{% extends 'YtManagerApp/controls/modal.html' %} +{% load crispy_forms_tags %} + +{% block modal_title %} + Edit subscription +{% endblock modal_title %} + +{% block modal_content %} +
+ {{ block.super }} +
+{% endblock %} + +{% block modal_body %} + {% crispy form %} +{% endblock modal_body %} + +{% block modal_footer %} + + +{% endblock modal_footer %} \ No newline at end of file diff --git a/YtManagerApp/templates/YtManagerApp/js/common.js b/YtManagerApp/templates/YtManagerApp/js/common.js index fdf451b..1f51ea2 100644 --- a/YtManagerApp/templates/YtManagerApp/js/common.js +++ b/YtManagerApp/templates/YtManagerApp/js/common.js @@ -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(); } diff --git a/YtManagerApp/templates/YtManagerApp/js/subscription_tree.js b/YtManagerApp/templates/YtManagerApp/js/subscription_tree.js index 5127f6b..0fc69cd 100644 --- a/YtManagerApp/templates/YtManagerApp/js/subscription_tree.js +++ b/YtManagerApp/templates/YtManagerApp/js/subscription_tree.js @@ -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); diff --git a/YtManagerApp/urls.py b/YtManagerApp/urls.py index 347bbbb..ec2216b 100644 --- a/YtManagerApp/urls.py +++ b/YtManagerApp/urls.py @@ -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//', UpdateFolderModal.as_view(), name='modal_update_folder'), path('modal/delete_folder//', DeleteFolderModal.as_view(), name='modal_delete_folder'), + path('modal/create_subscription/', CreateSubscriptionModal.as_view(), name='modal_create_subscription'), + path('modal/create_subscription//', CreateSubscriptionModal.as_view(), name='modal_create_subscription'), + path('modal/update_subscription//', UpdateSubscriptionModal.as_view(), name='modal_update_subscription'), + path('modal/delete_subscription//', DeleteSubscriptionModal.as_view(), name='modal_delete_subscription'), + # Index path('', index, name='home'), diff --git a/YtManagerApp/utils/youtube.py b/YtManagerApp/utils/youtube.py index ec66c6c..e58910e 100644 --- a/YtManagerApp/utils/youtube.py +++ b/YtManagerApp/utils/youtube.py @@ -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'] diff --git a/YtManagerApp/views/controls/modal.py b/YtManagerApp/views/controls/modal.py index 52f80dc..448aef7 100644 --- a/YtManagerApp/views/controls/modal.py +++ b/YtManagerApp/views/controls/modal.py @@ -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) diff --git a/YtManagerApp/views/index.py b/YtManagerApp/views/index.py index e1a5b90..b613728 100644 --- a/YtManagerApp/views/index.py +++ b/YtManagerApp/views/index.py @@ -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('
'), + HTML('
Download configuration overloads
'), + '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)