From e0f2f26c717e142cf331d791270ed97cb41d6627 Mon Sep 17 00:00:00 2001 From: n9k Date: Fri, 25 Feb 2022 23:06:36 +0000 Subject: [PATCH] Keep track of stream viewership (number of viewers) --- anonstream/__init__.py | 3 +- anonstream/helpers/user.py | 3 ++ anonstream/routes/nojs.py | 7 +++-- anonstream/static/anonstream.js | 44 +++++++++++++++++++++++++---- anonstream/static/style.css | 10 +++++-- anonstream/stream.py | 12 +++++++- anonstream/tasks.py | 18 +++++++++--- anonstream/templates/nojs_info.html | 18 ++++++++++-- anonstream/websocket.py | 7 +++-- 9 files changed, 101 insertions(+), 21 deletions(-) diff --git a/anonstream/__init__.py b/anonstream/__init__.py index 2fcbd73..30bf3f5 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -89,8 +89,9 @@ def create_app(config_file): # State for tasks app.users_update_buffer = set() - app.stream_uptime = None app.stream_title = None + app.stream_uptime = None + app.stream_viewership = None # Background tasks' asyncio.sleep tasks, cancelled on shutdown app.background_sleep = set() diff --git a/anonstream/helpers/user.py b/anonstream/helpers/user.py index 871026b..a1df64e 100644 --- a/anonstream/helpers/user.py +++ b/anonstream/helpers/user.py @@ -76,6 +76,9 @@ def get_presence(timestamp, user): return Presence.ABSENT +def is_watching(timestamp, user): + return get_presence(timestamp, user) == Presence.WATCHING + def is_listed(timestamp, user): return ( get_presence(timestamp, user) diff --git a/anonstream/routes/nojs.py b/anonstream/routes/nojs.py index 5f70556..b412a8b 100644 --- a/anonstream/routes/nojs.py +++ b/anonstream/routes/nojs.py @@ -2,11 +2,11 @@ from quart import current_app, request, render_template, redirect, url_for, esca from anonstream.captcha import get_random_captcha_digest_for from anonstream.chat import add_chat_message, Rejected -from anonstream.stream import get_stream_title, get_stream_uptime +from anonstream.stream import get_stream_title, get_stream_uptime, get_stream_viewership from anonstream.user import add_state, pop_state, try_change_appearance, verify, deverify, BadCaptcha from anonstream.routes.wrappers import with_user_from, render_template_with_etag from anonstream.helpers.chat import get_scrollback -from anonstream.helpers.user import get_default_name +from anonstream.helpers.user import get_default_name, Presence from anonstream.utils.chat import generate_nonce from anonstream.utils.user import concatenate_for_notice @@ -19,8 +19,9 @@ async def nojs_info(user): return await render_template( 'nojs_info.html', user=user, - title=await get_stream_title(), + viewership=get_stream_viewership(), uptime=get_stream_uptime(), + title=await get_stream_title(), ) @current_app.route('/chat/messages.html') diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index 649edc4..12769ea 100644 --- a/anonstream/static/anonstream.js +++ b/anonstream/static/anonstream.js @@ -6,7 +6,9 @@ const jsmarkup_style_color = '' const jsmarkup_style_tripcode_display = '' const jsmarkup_style_tripcode_colors = '' const jsmarkup_info = '
'; -const jsmarkup_info_uptime = ''; +const jsmarkup_info_float = ''; +const jsmarkup_info_float_viewership = '
'; +const jsmarkup_info_float_uptime = '
'; const jsmarkup_info_title = '
'; const jsmarkup_chat_messages = '
    '; const jsmarkup_chat_form = `\ @@ -23,7 +25,7 @@ const jsmarkup_chat_form = `\ `; -const insert_jsmarkup = () => { +const insert_jsmarkup = () => {jsmarkup_info_float_viewership if (document.getElementById("style-color") === null) { const parent = document.head; parent.insertAdjacentHTML("beforeend", jsmarkup_style_color); @@ -40,9 +42,17 @@ const insert_jsmarkup = () => { const parent = document.getElementById("info"); parent.insertAdjacentHTML("beforeend", jsmarkup_info); } - if (document.getElementById("info_js__uptime") === null) { + if (document.getElementById("info_js__float") === null) { const parent = document.getElementById("info_js"); - parent.insertAdjacentHTML("beforeend", jsmarkup_info_uptime); + parent.insertAdjacentHTML("beforeend", jsmarkup_info_float); + } + if (document.getElementById("info_js__float__viewership") === null) { + const parent = document.getElementById("info_js__float"); + parent.insertAdjacentHTML("beforeend", jsmarkup_info_float_viewership); + } + if (document.getElementById("info_js__float__uptime") === null) { + const parent = document.getElementById("info_js__float"); + parent.insertAdjacentHTML("beforeend", jsmarkup_info_float_uptime); } if (document.getElementById("info_js__title") === null) { const parent = document.getElementById("info_js"); @@ -65,7 +75,8 @@ const stylesheet_tripcode_colors = document.styleSheets[3]; /* create websocket */ const info_title = document.getElementById("info_js__title"); -const info_uptime = document.getElementById("info_js__uptime"); +const info_viewership = document.getElementById("info_js__float__viewership"); +const info_uptime = document.getElementById("info_js__float__uptime"); const chat_messages = document.getElementById("chat-messages_js"); const create_chat_message = (object) => { @@ -352,6 +363,10 @@ const update_uptime = () => { } setInterval(update_uptime, 1000); // always update uptime +const set_viewership = (n) => { + info_viewership.innerText = n === null ? "" : `${n} viewers`; +} + const on_websocket_message = (event) => { //console.log("websocket message", event); const receipt = JSON.parse(event.data); @@ -363,13 +378,23 @@ const on_websocket_message = (event) => { case "init": console.log("ws init", receipt); + // set title set_title(receipt.title); + + // set viewership + set_viewership(receipt.viewership); + + // set uptime set_frozen_uptime(receipt.uptime); update_uptime(); + // chat form nonce chat_form_nonce.value = receipt.nonce; + + // chat form captcha digest receipt.digest === null ? disable_captcha() : enable_captcha(receipt.digest); + // remove messages the server isn't acknowledging the existance of const seqs = new Set(receipt.messages.map((message) => {return message.seq;})); const to_delete = []; for (const chat_message of chat_messages.children) { @@ -382,13 +407,17 @@ const on_websocket_message = (event) => { chat_message.remove(); } + // settings default_name = receipt.default; max_chat_scrollback = receipt.scrollback; + + // appearances users = receipt.users; update_user_names(); update_user_colors(); update_user_tripcodes(); + // insert new messages const last = chat_messages.children.length == 0 ? null : chat_messages.children[chat_messages.children.length - 1]; const last_seq = last === null ? null : parseInt(last.dataset.seq); for (const message of receipt.messages) { @@ -408,6 +437,11 @@ const on_websocket_message = (event) => { set_frozen_uptime(receipt.uptime); update_uptime(); } + if (receipt.viewership === 0 && frozen_uptime === null) { + set_viewership(null); + } else if (receipt.viewership !== undefined) { + set_viewership(receipt.viewership); + } break; case "ack": diff --git a/anonstream/static/style.css b/anonstream/static/style.css index 227890b..07bf2fe 100644 --- a/anonstream/static/style.css +++ b/anonstream/static/style.css @@ -61,12 +61,18 @@ noscript { } #info_js { overflow-y: auto; - padding: 1ch 1.5ch; + padding: 0.75ch 1.25ch; height: 100%; } -#info_js__uptime { +#info_js__float { float: right; font-size: 11pt; + display: grid; + grid-auto-flow: column; + grid-gap: 2.5ch; +} +#info_js__float__uptime { + font-variant-numeric: tabular-nums; } #info_js__title > h1 { margin: 0; diff --git a/anonstream/stream.py b/anonstream/stream.py index 07472f7..e3eeebe 100644 --- a/anonstream/stream.py +++ b/anonstream/stream.py @@ -4,9 +4,11 @@ import aiofiles from quart import current_app from anonstream.segments import get_playlist, Offline -from anonstream.wrappers import ttl_cache_async +from anonstream.wrappers import ttl_cache_async, with_timestamp +from anonstream.helpers.user import is_watching CONFIG = current_app.config +USERS = current_app.users @ttl_cache_async(CONFIG['STREAM_TITLE_CACHE_LIFETIME']) async def get_stream_title(): @@ -32,6 +34,14 @@ def get_stream_uptime(rounded=True): uptime = round(uptime, 2) if rounded else uptime return uptime +@with_timestamp +def get_stream_viewership(timestamp): + return sum(map(lambda user: is_watching(timestamp, user), USERS)) + +def get_stream_viewership_or_none(uptime): + viewership = get_stream_viewership() + return uptime and viewership + def is_online(): try: get_playlist() diff --git a/anonstream/tasks.py b/anonstream/tasks.py index 002fd4a..d97af48 100644 --- a/anonstream/tasks.py +++ b/anonstream/tasks.py @@ -5,7 +5,7 @@ from functools import wraps from quart import current_app from anonstream.broadcast import broadcast, broadcast_users_update -from anonstream.stream import is_online, get_stream_title, get_stream_uptime +from anonstream.stream import is_online, get_stream_title, get_stream_uptime, get_stream_viewership_or_none from anonstream.wrappers import with_timestamp from anonstream.helpers.user import is_visible @@ -98,8 +98,12 @@ async def t_broadcast_users_update(iteration): @with_period(CONFIG['TASK_PERIOD_BROADCAST_STREAM_INFO_UPDATE']) async def t_broadcast_stream_info_update(iteration): if iteration == 0: - current_app.stream_title = await get_stream_title() - current_app.stream_uptime = get_stream_uptime() + title = await get_stream_title() + uptime = get_stream_uptime() + viewership = get_stream_viewership_or_none(uptime) + current_app.stream_title = title + current_app.stream_uptime = uptime + current_app.stream_viewership = viewership else: payload = {} @@ -109,7 +113,7 @@ async def t_broadcast_stream_info_update(iteration): current_app.stream_title = title payload['title'] = title - # Check if the stream uptime has changed differently than expected + # Check if the stream uptime has changed more or less than expected if current_app.stream_uptime is None: expected_uptime = None else: @@ -126,6 +130,12 @@ async def t_broadcast_stream_info_update(iteration): elif abs(uptime - expected_uptime) >= 0.0625: payload['uptime'] = uptime + # Check if viewership has changed + viewership = get_stream_viewership_or_none(uptime) + if current_app.stream_viewership != viewership: + current_app.stream_viewership = viewership + payload['viewership'] = viewership + if payload: broadcast(USERS, payload={'type': 'info', **payload}) diff --git a/anonstream/templates/nojs_info.html b/anonstream/templates/nojs_info.html index b9785a7..d026e07 100644 --- a/anonstream/templates/nojs_info.html +++ b/anonstream/templates/nojs_info.html @@ -2,16 +2,23 @@ + - + {% if uptime is not none %} + + {% endif %}

    {{ title }}

    diff --git a/anonstream/websocket.py b/anonstream/websocket.py index 26b1340..3b340f7 100644 --- a/anonstream/websocket.py +++ b/anonstream/websocket.py @@ -3,7 +3,7 @@ import json from quart import current_app, websocket -from anonstream.stream import get_stream_title, get_stream_uptime +from anonstream.stream import get_stream_title, get_stream_uptime, get_stream_viewership_or_none from anonstream.captcha import get_random_captcha_digest_for from anonstream.chat import get_all_messages_for_websocket, add_chat_message, Rejected from anonstream.user import get_all_users_for_websocket, see, verify, deverify, BadCaptcha @@ -13,11 +13,14 @@ from anonstream.utils.websocket import parse_websocket_data, Malformed CONFIG = current_app.config async def websocket_outbound(queue, user): + uptime = get_stream_uptime() + viewership = get_stream_viewership_or_none(uptime) payload = { 'type': 'init', 'nonce': generate_nonce(), 'title': await get_stream_title(), - 'uptime': get_stream_uptime(), + 'uptime': uptime, + 'viewership': viewership, 'messages': get_all_messages_for_websocket(), 'users': get_all_users_for_websocket(), 'default': {