mirror of
https://github.com/chibicitiberiu/ytsm.git
synced 2024-02-24 05:43:31 +00:00
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:
parent
7a87ad648a
commit
57c2265f71
@ -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()
|
||||||
|
93
app/YtManagerApp/management/notification_manager.py
Normal file
93
app/YtManagerApp/management/notification_manager.py
Normal 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)
|
@ -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 %}
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
@ -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>
|
||||||
|
@ -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'),
|
||||||
|
57
app/YtManagerApp/utils/algorithms.py
Normal file
57
app/YtManagerApp/utils/algorithms.py
Normal 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
|
@ -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:
|
||||||
|
12
app/YtManagerApp/views/notifications.py
Normal file
12
app/YtManagerApp/views/notifications.py
Normal 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)
|
Loading…
Reference in New Issue
Block a user