Added a basic notification system. Long processes will call the notification API, and the notification events are registered. For now, notifications are pushed to the client by polling (client polls every second for new events). A basic status message is now displayed when the sync process starts and ends.

This commit is contained in:
Tiberiu Chibici 2018-11-04 23:32:18 +02:00
parent 7a87ad648a
commit 57c2265f71
9 changed files with 205 additions and 2 deletions

View File

@ -10,6 +10,8 @@ from YtManagerApp.management.downloader import fetch_thumbnail, downloader_proce
from YtManagerApp.models import * from YtManagerApp.models import *
from YtManagerApp.utils import youtube from YtManagerApp.utils import youtube
from YtManagerApp.management import notification_manager
log = logging.getLogger('sync') log = logging.getLogger('sync')
__lock = Lock() __lock = Lock()
@ -104,6 +106,7 @@ def synchronize():
try: try:
log.info("Running scheduled synchronization... ") log.info("Running scheduled synchronization... ")
notification_manager.notify_status_update(f'Synchronization started for all subscriptions.')
# Sync subscribed playlists/channels # Sync subscribed playlists/channels
log.info("Sync - checking videos") log.info("Sync - checking videos")
@ -119,6 +122,7 @@ def synchronize():
__fetch_thumbnails() __fetch_thumbnails()
log.info("Synchronization finished.") log.info("Synchronization finished.")
notification_manager.notify_status_update(f'Synchronization finished for all subscriptions.')
finally: finally:
__lock.release() __lock.release()
@ -128,6 +132,8 @@ def synchronize_subscription(subscription: Subscription):
__lock.acquire() __lock.acquire()
try: try:
log.info("Running synchronization for single subscription %d [%s]", subscription.id, subscription.name) log.info("Running synchronization for single subscription %d [%s]", subscription.id, subscription.name)
notification_manager.notify_status_update(f'Synchronization started for subscription <strong>{subscription.name}</strong>.')
yt_api = youtube.YoutubeAPI.build_public() yt_api = youtube.YoutubeAPI.build_public()
log.info("Sync - checking videos") log.info("Sync - checking videos")
@ -141,6 +147,7 @@ def synchronize_subscription(subscription: Subscription):
__fetch_thumbnails() __fetch_thumbnails()
log.info("Synchronization finished for subscription %d [%s].", subscription.id, subscription.name) log.info("Synchronization finished for subscription %d [%s].", subscription.id, subscription.name)
notification_manager.notify_status_update(f'Synchronization finished for subscription <strong>{subscription.name}</strong>.')
finally: finally:
__lock.release() __lock.release()

View File

@ -0,0 +1,93 @@
from django.contrib.auth.models import User
from typing import Dict, Deque, Any, Optional
from collections import deque
from datetime import datetime, timedelta
from YtManagerApp.utils.algorithms import bisect_left
from threading import Lock
# Clients will request updates at most every few seconds, so a retention period of 60 seconds should be more than
# enough. I gave it 15 minutes so that if for some reason the connection fails (internet drops) and then comes back a
# few minutes later, the client will still get the updates
__RETENTION_PERIOD = 15 * 60
__NOTIFICATIONS: Deque[Dict] = deque()
__NEXT_ID = 0
__LOCK = Lock()
# Messages enum
class Messages:
STATUS_UPDATE = 'st-up'
STATUS_OPERATION_PROGRESS = 'st-op-prog'
STATUS_OPERATION_END = 'st-op-end'
def __add_notification(message, user: User=None, **kwargs):
global __NEXT_ID
__LOCK.acquire()
try:
# add notification
notification = {
'time': datetime.now(),
'msg': message,
'id': __NEXT_ID,
'uid': user and user.id,
}
notification.update(kwargs)
__NOTIFICATIONS.append(notification)
__NEXT_ID += 1
# trim old notifications
oldest = __NOTIFICATIONS[0]
while len(__NOTIFICATIONS) > 0 and oldest['time'] + timedelta(seconds=__RETENTION_PERIOD) < datetime.now():
__NOTIFICATIONS.popleft()
oldest = __NOTIFICATIONS[0]
finally:
__LOCK.release()
def get_notifications(user: User, last_received_id: Optional[int]):
__LOCK.acquire()
try:
first_index = 0
if last_received_id is not None:
first_index = bisect_left(__NOTIFICATIONS,
{'id': last_received_id},
key=lambda item: item['id'])
for i in range(first_index, len(__NOTIFICATIONS)):
item = __NOTIFICATIONS[i]
if item['uid'] is None or item['uid'] == user.id:
yield item
finally:
__LOCK.release()
def get_current_notification_id():
return __NEXT_ID
def notify_status_update(status_message: str, user: User=None):
__add_notification(Messages.STATUS_UPDATE,
user=user,
status=status_message)
def notify_status_operation_progress(op_id: Any, status_message: str, progress_percent: float, user: User=None):
__add_notification(Messages.STATUS_OPERATION_PROGRESS,
user=user,
operation=op_id,
status=status_message,
progress=progress_percent)
def notify_status_operation_ended(op_id: Any, status_message: str, user: User=None):
__add_notification(Messages.STATUS_OPERATION_END,
user=user,
operation=op_id,
status=status_message)

View File

@ -9,6 +9,8 @@
{% block scripts %} {% block scripts %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jstree/3.2.1/jstree.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jstree/3.2.1/jstree.min.js"></script>
<script> <script>
let LAST_NOTIFICATION_ID = {{ current_notification_id }};
{% include 'YtManagerApp/js/index.js' %} {% include 'YtManagerApp/js/index.js' %}
</script> </script>
{% endblock %} {% endblock %}

View File

@ -157,6 +157,30 @@ function videos_Submit(e)
e.preventDefault(); e.preventDefault();
} }
///
/// Notifications
///
const NOTIFICATION_INTERVAL = 1000;
function get_and_process_notifications()
{
$.get("{% url 'ajax_get_notifications' 12345 %}".replace("12345", LAST_NOTIFICATION_ID))
.done(function(data) {
for (let entry of data)
{
LAST_NOTIFICATION_ID = entry.id;
let dt = new Date(entry.time);
// Status update
if (entry.msg === 'st-up') {
let txt = `<span class="status-timestamp">${dt.getHours()}:${dt.getMinutes()}</span>${entry.status}`;
$('#status-message').html(txt);
}
}
});
}
/// ///
/// Initialization /// Initialization
/// ///
@ -186,4 +210,7 @@ $(document).ready(function ()
filters_form.find('select[name=show_watched]').on('change', videos_ReloadWithTimer); filters_form.find('select[name=show_watched]').on('change', videos_ReloadWithTimer);
filters_form.find('select[name=show_downloaded]').on('change', videos_ReloadWithTimer); filters_form.find('select[name=show_downloaded]').on('change', videos_ReloadWithTimer);
videos_Reload(); videos_Reload();
// Notification manager
setInterval(get_and_process_notifications, NOTIFICATION_INTERVAL);
}); });

View File

@ -67,7 +67,7 @@
</div> </div>
<footer id="main_footer" class="footer bg-light"> <footer id="main_footer" class="footer bg-light">
<span class="ml-auto text-muted">Last synchronized: just now</span> <span id="status-message" class="ml-auto text-muted">Last synchronized: just now</span>
<button id="btn_sync_now" class="btn btn-sm btn-light" title="Synchronize now!"> <button id="btn_sync_now" class="btn btn-sm btn-light" title="Synchronize now!">
<span class="typcn typcn-arrow-sync" aria-hidden="true"></span> <span class="typcn typcn-arrow-sync" aria-hidden="true"></span>
</button> </button>

View File

@ -24,6 +24,7 @@ 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
from .views.settings import SettingsView from .views.settings import SettingsView
from .views.notifications import ajax_get_notifications
urlpatterns = [ urlpatterns = [
# Authentication URLs # Authentication URLs
@ -42,6 +43,8 @@ urlpatterns = [
path('ajax/get_tree/', ajax_get_tree, name='ajax_get_tree'), path('ajax/get_tree/', ajax_get_tree, name='ajax_get_tree'),
path('ajax/get_videos/', ajax_get_videos, name='ajax_get_videos'), path('ajax/get_videos/', ajax_get_videos, name='ajax_get_videos'),
path('ajax/get_notifications/<int:last_id>', ajax_get_notifications, name='ajax_get_notifications'),
# Modals # Modals
path('modal/create_folder/', CreateFolderModal.as_view(), name='modal_create_folder'), path('modal/create_folder/', CreateFolderModal.as_view(), name='modal_create_folder'),
path('modal/create_folder/<int:parent_id>/', CreateFolderModal.as_view(), name='modal_create_folder'), path('modal/create_folder/<int:parent_id>/', CreateFolderModal.as_view(), name='modal_create_folder'),

View File

@ -0,0 +1,57 @@
"""Bisection algorithms.
These algorithms are taken from Python's standard library, and modified so they take a 'key' parameter (similar to how
`sorted` works).
"""
def bisect_right(a, x, lo=0, hi=None, key=None):
"""Return the index where to insert item x in list a, assuming a is sorted.
The return value i is such that all e in a[:i] have e <= x, and all e in
a[i:] have e > x. So if x already appears in the list, a.insert(x) will
insert just after the rightmost x already there.
Optional args lo (default 0) and hi (default len(a)) bound the
slice of a to be searched.
"""
if key is None:
key = lambda x: x
if lo < 0:
raise ValueError('lo must be non-negative')
if hi is None:
hi = len(a)
while lo < hi:
mid = (lo+hi)//2
if key(x) < key(a[mid]): hi = mid
else: lo = mid+1
return lo
def bisect_left(a, x, lo=0, hi=None, key=None):
"""Return the index where to insert item x in list a, assuming a is sorted.
The return value i is such that all e in a[:i] have e < x, and all e in
a[i:] have e >= x. So if x already appears in the list, a.insert(x) will
insert just before the leftmost x already there.
Optional args lo (default 0) and hi (default len(a)) bound the
slice of a to be searched.
"""
if key is None:
key = lambda x: x
if lo < 0:
raise ValueError('lo must be non-negative')
if hi is None:
hi = len(a)
while lo < hi:
mid = (lo+hi)//2
if key(a[mid]) < key(x): lo = mid+1
else: hi = mid
return lo
# Create aliases
bisect = bisect_right

View File

@ -13,6 +13,7 @@ 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
from YtManagerApp.views.controls.modal import ModalMixin from YtManagerApp.views.controls.modal import ModalMixin
from YtManagerApp.management.notification_manager import get_current_notification_id
class VideoFilterForm(forms.Form): class VideoFilterForm(forms.Form):
@ -94,7 +95,8 @@ def __tree_sub_id(sub_id):
def index(request: HttpRequest): def index(request: HttpRequest):
if request.user.is_authenticated: if request.user.is_authenticated:
context = { context = {
'filter_form': VideoFilterForm() 'filter_form': VideoFilterForm(),
'current_notification_id': get_current_notification_id(),
} }
return render(request, 'YtManagerApp/index.html', context) return render(request, 'YtManagerApp/index.html', context)
else: else:

View File

@ -0,0 +1,12 @@
from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, JsonResponse
from YtManagerApp.management.notification_manager import get_notifications
@login_required
def ajax_get_notifications(request: HttpRequest, last_id: int):
user = request.user
notifications = get_notifications(user, last_id)
notifications = list(notifications)
return JsonResponse(notifications, safe=False)