Implemented import from file functionality.

This commit is contained in:
Tiberiu Chibici 2018-11-03 14:43:23 +02:00
parent cd37b2671b
commit 59a766b0fe
6 changed files with 253 additions and 9 deletions

View File

@ -0,0 +1,22 @@
{% extends 'YtManagerApp/controls/modal.html' %}
{% load crispy_forms_tags %}
{% block modal_title %}
Import subscriptions
{% endblock modal_title %}
{% block modal_content %}
<form action="{% url 'modal_import_subscriptions' %}" method="post"
enctype="multipart/form-data">
{{ block.super }}
</form>
{% endblock %}
{% block modal_body %}
{% crispy form %}
{% endblock modal_body %}
{% block modal_footer %}
<input class="btn btn-primary" type="submit" value="Import">
<input class="btn btn-secondary" type="button" value="Cancel" data-dismiss="modal" aria-label="Cancel">
{% endblock modal_footer %}

View File

@ -29,19 +29,27 @@
<div class="col-3"> <div class="col-3">
{# Tree toolbar #} {# Tree toolbar #}
<div class="btn-toolbar" role="toolbar" aria-label="Subscriptions toolbar"> <div class="btn-toolbar" role="toolbar" aria-label="Subscriptions toolbar">
<div class="btn-group btn-group-sm mr-2" role="group"> <div class="btn-group mr-2" role="group">
<button id="btn_create_sub" type="button" class="btn btn-secondary" > <button id="btn_create_sub" type="button" class="btn btn-light"
data-toggle="tooltip" title="Add subscription...">
<span class="typcn typcn-plus" aria-hidden="true"></span> <span class="typcn typcn-plus" aria-hidden="true"></span>
</button> </button>
<button id="btn_create_folder" type="button" class="btn btn-secondary"> <button id="btn_create_folder" type="button" class="btn btn-light"
data-toggle="tooltip" title="Add folder...">
<span class="typcn typcn-folder-add" aria-hidden="true"></span> <span class="typcn typcn-folder-add" aria-hidden="true"></span>
</button> </button>
<button id="btn_import" type="button" class="btn btn-light"
data-toggle="tooltip" title="Import from file...">
<span class="typcn typcn-document-add" aria-hidden="true"></span>
</button>
</div> </div>
<div class="btn-group btn-group-sm mr-2" role="group"> <div class="btn-group mr-2" role="group">
<button id="btn_edit_node" type="button" class="btn btn-secondary" > <button id="btn_edit_node" type="button" class="btn btn-light"
data-toggle="tooltip" title="Edit selection">
<span class="typcn typcn-edit" aria-hidden="true"></span> <span class="typcn typcn-edit" aria-hidden="true"></span>
</button> </button>
<button id="btn_delete_node" type="button" class="btn btn-secondary" > <button id="btn_delete_node" type="button" class="btn btn-light"
data-toggle="tooltip" title="Delete selection">
<span class="typcn typcn-trash" aria-hidden="true"></span> <span class="typcn typcn-trash" aria-hidden="true"></span>
</button> </button>
</div> </div>

View File

@ -162,6 +162,9 @@ function videos_Submit(e)
/// ///
$(document).ready(function () $(document).ready(function ()
{ {
// Initialize tooltips
$('[data-toggle="tooltip"]').tooltip();
tree_Initialize(); tree_Initialize();
// Subscription toolbar // Subscription toolbar
@ -175,6 +178,11 @@ $(document).ready(function ()
modal.setSubmitCallback(tree_Refresh); modal.setSubmitCallback(tree_Refresh);
modal.loadAndShow(); modal.loadAndShow();
}); });
$("#btn_import").on("click", function () {
let modal = new AjaxModal("{% url 'modal_import_subscriptions' %}");
modal.setSubmitCallback(tree_Refresh);
modal.loadAndShow();
});
$("#btn_edit_node").on("click", treeNode_Edit); $("#btn_edit_node").on("click", treeNode_Edit);
$("#btn_delete_node").on("click", treeNode_Delete); $("#btn_delete_node").on("click", treeNode_Delete);

View File

@ -22,7 +22,7 @@ from .views.actions import SyncNowView, DeleteVideoFilesView, DownloadVideoFiles
MarkVideoUnwatchedView MarkVideoUnwatchedView
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 CreateSubscriptionModal, UpdateSubscriptionModal, DeleteSubscriptionModal, ImportSubscriptionsModal
from .views.settings import SettingsView from .views.settings import SettingsView
urlpatterns = [ urlpatterns = [
@ -50,6 +50,8 @@ urlpatterns = [
path('modal/create_subscription/', CreateSubscriptionModal.as_view(), name='modal_create_subscription'), 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/create_subscription/<int:parent_folder_id>/', CreateSubscriptionModal.as_view(), name='modal_create_subscription'),
path('modal/import_subscriptions/', ImportSubscriptionsModal.as_view(), name='modal_import_subscriptions'),
path('modal/import_subscriptions/<int:parent_folder_id>/', ImportSubscriptionsModal.as_view(), name='modal_import_subscriptions'),
path('modal/update_subscription/<int:pk>/', UpdateSubscriptionModal.as_view(), name='modal_update_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'), path('modal/delete_subscription/<int:pk>/', DeleteSubscriptionModal.as_view(), name='modal_delete_subscription'),

View File

@ -0,0 +1,104 @@
from typing import Iterable
from xml.etree import ElementTree
import re
class FormatNotSupportedError(Exception):
pass
class SubFileParser(object):
def probe(self, file_handle) -> bool:
"""
Tests if file matches file format.
:param file: File path
:return: True if file matches, false otherwise
"""
return False
def parse(self, file_handle) -> Iterable[str]:
"""
Parses file and returns a list of subscription URLs.
:param file:
:return:
"""
return []
class SubscriptionListFileParser(SubFileParser):
"""
A subscription list file is file which contains just a bunch of URLs.
Comments are supported using # character.
"""
def __is_url(self, text: str) -> bool:
return text.startswith('http://') or text.startswith('https://')
def probe(self, file_handle):
file_handle.seek(0)
for line in file_handle:
# Trim comments and spaces
line = re.sub('#.*', '', line).strip()
if len(line) > 0:
return self.__is_url(line)
return False
def parse(self, file_handle):
file_handle.seek(0)
for line in file_handle:
# Trim comments and spaces
line = re.sub('#.*', '', line).strip()
if len(line) > 0:
yield line
class OPMLParser(SubFileParser):
"""
Parses OPML files (emitted by YouTube)
"""
def __init__(self):
self.__cached_file = None
self.__cached_tree: ElementTree.ElementTree = None
def __parse(self, file_handle):
if file_handle == self.__cached_file:
return self.__cached_tree
file_handle.seek(0)
tree = ElementTree.parse(file_handle)
self.__cached_file = file_handle
self.__cached_tree = tree
return self.__cached_tree
def probe(self, file_handle):
try:
tree = self.__parse(file_handle)
except ElementTree.ParseError:
# Malformed XML
return False
return tree.getroot().tag.lower() == 'opml'
def parse(self, file_handle):
tree = self.__parse(file_handle)
root = tree.getroot()
for node in root.iter('outline'):
if 'xmlUrl' in node.keys():
yield node.get('xmlUrl')
PARSERS = (
OPMLParser(),
SubscriptionListFileParser()
)
def parse(file_handle) -> Iterable[str]:
for parser in PARSERS:
if parser.probe(file_handle):
return parser.parse(file_handle)
raise FormatNotSupportedError('This file cannot be parsed!')

View File

@ -6,14 +6,16 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q from django.db.models import Q
from django.http import HttpRequest, HttpResponseBadRequest, JsonResponse from django.http import HttpRequest, HttpResponseBadRequest, JsonResponse
from django.shortcuts import render from django.shortcuts import render
from django.views.generic import CreateView, UpdateView, DeleteView from django.views.generic import CreateView, UpdateView, DeleteView, FormView
from django.views.generic.edit import FormMixin 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, VIDEO_ORDER_CHOICES, VIDEO_ORDER_MAPPING from YtManagerApp.models import Subscription, SubscriptionFolder, VIDEO_ORDER_CHOICES, VIDEO_ORDER_MAPPING
from YtManagerApp.utils import youtube from YtManagerApp.utils import youtube, subscription_file_parser
from YtManagerApp.views.controls.modal import ModalMixin from YtManagerApp.views.controls.modal import ModalMixin
import logging
class VideoFilterForm(forms.Form): class VideoFilterForm(forms.Form):
CHOICES_SHOW_WATCHED = ( CHOICES_SHOW_WATCHED = (
@ -346,3 +348,101 @@ class DeleteSubscriptionModal(LoginRequiredMixin, ModalMixin, FormMixin, DeleteV
def form_valid(self, form): def form_valid(self, form):
self.object.delete_subscription(keep_downloaded_videos=form.cleaned_data['keep_downloaded_videos']) self.object.delete_subscription(keep_downloaded_videos=form.cleaned_data['keep_downloaded_videos'])
return super().form_valid(form) return super().form_valid(form)
class ImportSubscriptionsForm(forms.Form):
TRUE_FALSE_CHOICES = (
(None, '(default)'),
(True, 'Yes'),
(False, 'No')
)
VIDEO_ORDER_CHOICES_WITH_EMPTY = (
('', '(default)'),
*VIDEO_ORDER_CHOICES,
)
file = forms.FileField(label='File to import',
help_text='Supported file types: OPML, subscription list')
parent_folder = forms.ModelChoiceField(SubscriptionFolder.objects, required=False)
auto_download = forms.ChoiceField(choices=TRUE_FALSE_CHOICES, required=False)
download_limit = forms.IntegerField(required=False)
download_order = forms.ChoiceField(choices=VIDEO_ORDER_CHOICES_WITH_EMPTY, required=False)
delete_after_watched = forms.ChoiceField(choices=TRUE_FALSE_CHOICES, required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.yt_api = youtube.YoutubeAPI.build_public()
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
'file',
'parent_folder',
HTML('<hr>'),
HTML('<h5>Download configuration overloads</h5>'),
'auto_download',
'download_limit',
'download_order',
'delete_after_watched'
)
def __clean_empty_none(self, name: str):
data = self.cleaned_data[name]
if isinstance(data, str) and len(data) == 0:
return None
return data
def __clean_boolean(self, name: str):
data = self.cleaned_data[name]
if isinstance(data, str) and len(data) == 0:
return None
if isinstance(data, str):
return data == 'True'
return data
def clean_auto_download(self):
return self.__clean_boolean('auto_download')
def clean_delete_after_watched(self):
return self.__clean_boolean('delete_after_watched')
def clean_download_order(self):
return self.__clean_empty_none('download_order')
class ImportSubscriptionsModal(LoginRequiredMixin, ModalMixin, FormView):
template_name = 'YtManagerApp/controls/subscriptions_import_modal.html'
form_class = ImportSubscriptionsForm
def form_valid(self, form):
file = form.cleaned_data['file']
# Parse file
try:
url_list = list(subscription_file_parser.parse(file))
except subscription_file_parser.FormatNotSupportedError:
return super().modal_response(form, success=False,
error_msg="The file could not be parsed! "
"Possible problems: format not supported, file is malformed.")
print(form.cleaned_data)
# Create subscriptions
api = youtube.YoutubeAPI.build_public()
for url in url_list:
sub = Subscription()
sub.user = self.request.user
sub.parent_folder = form.cleaned_data['parent_folder']
sub.auto_download = form.cleaned_data['auto_download']
sub.download_limit = form.cleaned_data['download_limit']
sub.download_order = form.cleaned_data['download_order']
sub.delete_after_watched = form.cleaned_data['delete_after_watched']
try:
sub.fetch_from_url(url, api)
except Exception as e:
logging.error("Import subscription error - error processing URL %s: %s", url, e)
continue
sub.save()
return super().form_valid(form)