コミットを比較

...

14 コミット

作成者 SHA1 メッセージ 日付
n9k c663bcabc5 Add meta viewport tags 2022-03-04 01:58:10 +13:00
n9k 14532f9500 Nojs chat: add fallback meta refresh to redirect url
Hacky workaround of weird behaviour in Firefox where on a page whose url has a
fragment/hash/anchor in it, sometimes a urlless meta refresh tag will jump to
the element instead of refreshing the page. Same thing happens if the meta
refresh tag's url component is the same as the page's url.
2022-03-04 01:58:10 +13:00
n9k 0e368a5fdc Add nojs 'Reload stream' button
The nojs button appears when the stream is online and the user is not watching.
The js button appears when the stream is online and the media element either
(1) is not using the network or (2) fires an error event.
2022-03-04 01:58:10 +13:00
n9k 8cb1c308b8 Nojs chat: only deverify user when they leave a message
Matches the behaviour of the js chat. Makes it so if you submit an empty
message but with a correct captcha, you won't be deverified and given another
captcha until you successfully send a message (and exceed the flood threshold).
Previously you could fill in the captcha with no message and be given back a
new captcha, which doesn't make that much sense.
2022-03-04 01:58:10 +13:00
n9k 9e656b3d37 Add `?token=...` to every url 2022-03-04 01:58:10 +13:00
n9k d83cd8da48 Add 'Reload stream' button in js 2022-03-04 01:58:10 +13:00
n9k c790ab8026 Show tripcodes in users list 2022-03-04 00:23:09 +13:00
n9k d35d3fc13d WS: combine `uptime` and `viewership` into `stats`
If the stream is offline, `stats` is null, otherwise it contains uptime and
viewership.
2022-03-04 00:23:09 +13:00
n9k 7591c0ad26 Minor changes to the appearance of the users list
Made the 'Users in chat' header above the overflow area, so it always stays on
top. Now using `visibility: hidden;` instead of `display: none;` to show/hide
messages/users so that nojs css animations don't reset.
2022-03-04 00:23:09 +13:00
n9k fa5225d76d Nojs users list: add meta refresh tag & timeout 2022-03-04 00:23:09 +13:00
n9k da9293b83f Show list of watching/non-watching users with js 2022-03-04 00:23:09 +13:00
n9k 1021cd78c5 Tell websockets which users are watching
This adds a field 'watching' in `user_for_websocket` that's True iff WATCHING,
False iff NOTWATCHING, and None otherwise (since clients don't need to know if
a user is tentative or absent). When the value of this field changes for any
user, they get added to the update buffer (like with any other change).

Removed race condition in `t_sunset_users`: `broadcast_users_update` was being
called *after* a user was removed from memory (and for each user being removed,
which was redundant). In that scenario if there's a user in the update buffer
and `t_sunset_users` wins the race between it and `t_broadcast_users_update`,
then when `t_sunset_users` calls `broadcast_users_update` a KeyError would be
raised since the user's already been removed.

Fixed unintended behaviour of `t_sunset_users`: it was removing users based on
the result of `is_visible`, so users who were actually tenative (as opposed to
absent) were being removed.
2022-03-04 00:23:09 +13:00
n9k e55338fdad CSS: make users button lighter 2022-03-04 00:23:09 +13:00
n9k eeb45f9836 Nojs chat: add list of watching/non-watching users 2022-03-04 00:22:52 +13:00
17個のファイルの変更721行の追加214行の削除

ファイルの表示

@ -1,25 +1,15 @@
import hashlib
import base64
from collections import OrderedDict
from enum import Enum
from math import inf
from quart import current_app
from anonstream.utils.colour import generate_colour, colour_to_color
from anonstream.utils.user import Presence
CONFIG = current_app.config
Presence = Enum(
'Presence',
names=(
'WATCHING',
'NOTWATCHING',
'TENTATIVE',
'ABSENT',
)
)
def generate_token_hash_and_tag(token):
parts = CONFIG['SECRET_KEY'] + b'token-hash\0' + token.encode()
digest = hashlib.sha256(parts).digest()
@ -29,7 +19,7 @@ def generate_token_hash_and_tag(token):
return token_hash, tag
def generate_user(timestamp, token, broadcaster):
def generate_user(timestamp, token, broadcaster, presence):
colour = generate_colour(
seed='name\0' + token,
bg=CONFIG['CHAT_BACKGROUND_COLOUR'],
@ -51,6 +41,7 @@ def generate_user(timestamp, token, broadcaster):
'seen': timestamp,
'watching': -inf,
},
'presence': presence,
}
def get_default_name(user):
@ -75,20 +66,3 @@ def get_presence(timestamp, user):
return Presence.TENTATIVE
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)
in {Presence.WATCHING, Presence.NOTWATCHING}
)
def is_visible(timestamp, messages, user):
def user_left_messages():
return any(
message['token'] == user['token']
for message in messages
)
return is_listed(timestamp, user) or user_left_messages()

ファイルの表示

@ -2,26 +2,37 @@ 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, get_stream_viewership
from anonstream.user import add_state, pop_state, try_change_appearance, verify, deverify, BadCaptcha
from anonstream.stream import get_stream_title, get_stream_uptime_and_viewership
from anonstream.user import add_state, pop_state, try_change_appearance, update_presence, get_users_by_presence, Presence, 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, Presence
from anonstream.helpers.user import get_default_name
from anonstream.utils.chat import generate_nonce
from anonstream.utils.user import concatenate_for_notice
CONFIG = current_app.config
USERS_BY_TOKEN = current_app.users_by_token
@current_app.route('/stream.html')
@with_user_from(request)
async def nojs_stream(user):
return await render_template(
'nojs_stream.html',
user=user,
)
@current_app.route('/info.html')
@with_user_from(request)
async def nojs_info(user):
update_presence(user)
uptime, viewership = get_stream_uptime_and_viewership()
return await render_template(
'nojs_info.html',
user=user,
viewership=get_stream_viewership(),
uptime=get_stream_uptime(),
viewership=viewership,
uptime=uptime,
title=await get_stream_title(),
Presence=Presence,
)
@current_app.route('/chat/messages.html')
@ -39,7 +50,20 @@ async def nojs_chat(user):
@current_app.route('/chat/messages')
@with_user_from(request)
async def nojs_chat_redirect(user):
return redirect(url_for('nojs_chat', _anchor='end'))
return redirect(url_for('nojs_chat', token=user['token'], _anchor='end'))
@current_app.route('/chat/users.html')
@with_user_from(request)
async def nojs_users(user):
users_by_presence = get_users_by_presence()
return await render_template_with_etag(
'nojs_users.html',
user=user,
get_default_name=get_default_name,
users_watching=users_by_presence[Presence.WATCHING],
users_notwatching=users_by_presence[Presence.NOTWATCHING],
timeout=CONFIG['THRESHOLD_NOJS_CHAT_TIMEOUT'],
)
@current_app.route('/chat/form.html')
@with_user_from(request)
@ -69,7 +93,7 @@ async def nojs_form_redirect(user):
else:
state_id = None
return redirect(url_for('nojs_form', state=state_id))
return redirect(url_for('nojs_form', token=user['token'], state=state_id))
@current_app.post('/chat/message')
@with_user_from(request)
@ -89,7 +113,7 @@ async def nojs_submit_message(user):
try:
# If the comment is empty but the captcha was just solved,
# be lenient: don't raise an exception and don't create a notice
add_chat_message(
message_was_added = add_chat_message(
user,
nonce,
comment,
@ -99,8 +123,9 @@ async def nojs_submit_message(user):
notice, *_ = e.args
state_id = add_state(user, notice=notice, comment=comment)
else:
deverify(user)
state_id = None
if message_was_added:
deverify(user)
return redirect(url_for(
'nojs_form',
@ -113,23 +138,24 @@ async def nojs_submit_message(user):
@with_user_from(request)
async def nojs_submit_appearance(user):
form = await request.form
name = form.get('name', '').strip()
color = form.get('color', '')
password = form.get('password', '')
want_delete_tripcode = form.get('clear-tripcode', type=bool)
want_change_tripcode = form.get('set-tripcode', type=bool)
# Collect form data
name = form.get('name', '').strip()
if len(name) == 0 or name == get_default_name(user):
name = None
errors = try_change_appearance(
user,
name,
color,
password,
want_delete_tripcode,
want_change_tripcode,
)
color = form.get('color', '')
password = form.get('password', '')
if form.get('clear-tripcode', type=bool):
want_tripcode = False
elif form.get('set-tripcode', type=bool):
want_tripcode = True
else:
want_tripcode = None
# Change appearance (iff form data was good)
errors = try_change_appearance(user, name, color, password, want_tripcode)
if errors:
notice = Markup('<br>').join(
concatenate_for_notice(*error.args) for error in errors

ファイルの表示

@ -8,7 +8,7 @@ from werkzeug.security import check_password_hash
from anonstream.broadcast import broadcast
from anonstream.user import see
from anonstream.helpers.user import generate_user
from anonstream.utils.user import generate_token
from anonstream.utils.user import generate_token, Presence
CONFIG = current_app.config
MESSAGES = current_app.messages
@ -68,6 +68,7 @@ def with_user_from(context):
timestamp=timestamp,
token=token,
broadcaster=broadcaster,
presence=Presence.NOTWATCHING,
)
USERS_BY_TOKEN[token] = user

ファイルの表示

@ -1,16 +1,27 @@
/* token */
const token = document.body.dataset.token;
const TOKEN = document.body.dataset.token;
const TOKEN_HASH = document.body.dataset.tokenHash;
/* insert js-only markup */
const jsmarkup_style_color = '<style id="style-color"></style>'
const jsmarkup_style_tripcode_display = '<style id="style-tripcode-display"></style>'
const jsmarkup_style_tripcode_colors = '<style id="style-tripcode-colors"></style>'
const jsmarkup_stream = `<video id="stream_js" src="/stream.mp4?token=${encodeURIComponent(TOKEN)}" autoplay controls></video>`
const jsmarkup_info = '<div id="info_js" data-js="true"></div>';
const jsmarkup_info_float = '<aside id="info_js__float"></aside>';
const jsmarkup_info_float_button = '<button id="info_js__float__button">Reload stream</button>';
const jsmarkup_info_float_viewership = '<div id="info_js__float__viewership"></div>';
const jsmarkup_info_float_uptime = '<div id="info_js__float__uptime"></div>';
const jsmarkup_info_title = '<header id="info_js__title"></header>';
const jsmarkup_chat_messages = '<ol id="chat-messages_js" data-js="true"></ol>';
const jsmarkup_chat_users = `\
<article id="chat-users_js">
<h5 id="chat-users_js__watching-header"></h5>
<ul id="chat-users_js__watching"></ul>
<br>
<h5 id="chat-users_js__notwatching-header"></h5>
<ul id="chat-users_js__notwatching"></ul>
</article>`;
const jsmarkup_chat_form = `\
<form id="chat-form_js" data-js="true" action="/chat" method="post">
<input id="chat-form_js__nonce" type="hidden" name="nonce" value="">
@ -38,6 +49,10 @@ const insert_jsmarkup = () => {jsmarkup_info_float_viewership
const parent = document.head;
parent.insertAdjacentHTML("beforeend", jsmarkup_style_tripcode_colors);
}
if (document.getElementById("stream_js") === null) {
const parent = document.getElementById("stream");
parent.insertAdjacentHTML("beforeend", jsmarkup_stream);
}
if (document.getElementById("info_js") === null) {
const parent = document.getElementById("info");
parent.insertAdjacentHTML("beforeend", jsmarkup_info);
@ -46,6 +61,10 @@ const insert_jsmarkup = () => {jsmarkup_info_float_viewership
const parent = document.getElementById("info_js");
parent.insertAdjacentHTML("beforeend", jsmarkup_info_float);
}
if (document.getElementById("info_js__float__button") === null) {
const parent = document.getElementById("info_js__float");
parent.insertAdjacentHTML("beforeend", jsmarkup_info_float_button);
}
if (document.getElementById("info_js__float__viewership") === null) {
const parent = document.getElementById("info_js__float");
parent.insertAdjacentHTML("beforeend", jsmarkup_info_float_viewership);
@ -58,8 +77,12 @@ const insert_jsmarkup = () => {jsmarkup_info_float_viewership
const parent = document.getElementById("info_js");
parent.insertAdjacentHTML("beforeend", jsmarkup_info_title);
}
if (document.getElementById("chat-users_js") === null) {
const parent = document.getElementById("chat__body__users");
parent.insertAdjacentHTML("beforeend", jsmarkup_chat_users);
}
if (document.getElementById("chat-messages_js") === null) {
const parent = document.getElementById("chat__messages");
const parent = document.getElementById("chat__body__messages");
parent.insertAdjacentHTML("beforeend", jsmarkup_chat_messages);
}
if (document.getElementById("chat-form_js") === null) {
@ -78,6 +101,10 @@ const info_title = document.getElementById("info_js__title");
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 chat_users_watching = document.getElementById("chat-users_js__watching");
const chat_users_watching_header = document.getElementById("chat-users_js__watching-header");
const chat_users_notwatching = document.getElementById("chat-users_js__notwatching");
const chat_users_notwatching_header = document.getElementById("chat-users_js__notwatching-header");
const create_chat_message = (object) => {
const user = users[object.token_hash];
@ -93,18 +120,11 @@ const create_chat_message = (object) => {
chat_message_time.title = `${object.date} ${object.time_seconds}`;
chat_message_time.innerText = object.time_minutes;
const chat_message_name = create_chat_message_name(user);
const chat_message_tripcode_nbsp = document.createElement("span");
chat_message_tripcode_nbsp.classList.add("for-tripcode");
chat_message_tripcode_nbsp.innerHTML = "&nbsp;";
const chat_message_tripcode = document.createElement("span");
chat_message_tripcode.classList.add("tripcode");
chat_message_tripcode.classList.add("for-tripcode");
if (user.tripcode !== null) {
chat_message_tripcode.innerHTML = user.tripcode.digest;
}
const [
chat_message_name,
chat_message_tripcode_nbsp,
chat_message_tripcode,
] = create_chat_user_components(user);
const chat_message_markup = document.createElement("span");
chat_message_markup.classList.add("chat-message__markup");
@ -120,18 +140,34 @@ const create_chat_message = (object) => {
return chat_message;
}
const create_chat_message_name = (user) => {
const chat_message_name = document.createElement("span");
chat_message_name.classList.add("chat-message__name");
chat_message_name.innerText = get_user_name({user});
//chat_message_name.dataset.color = user.color; // not working in any browser
const create_chat_user_name = (user) => {
const chat_user_name = document.createElement("span");
chat_user_name.classList.add("chat-name");
chat_user_name.innerText = get_user_name({user});
//chat_user_name.dataset.color = user.color; // not working in any browser
if (!user.broadcaster && user.name === null) {
const chat_message_name_tag = document.createElement("sup");
chat_message_name_tag.classList.add("chat-message__name__tag");
chat_message_name_tag.innerText = user.tag;
chat_message_name.insertAdjacentElement("beforeend", chat_message_name_tag);
const chat_user_name_tag = document.createElement("sup");
chat_user_name_tag.classList.add("chat-name__tag");
chat_user_name_tag.innerText = user.tag;
chat_user_name.insertAdjacentElement("beforeend", chat_user_name_tag);
}
return chat_message_name;
return chat_user_name;
}
const create_chat_user_components = (user) => {
const chat_user_name = create_chat_user_name(user);
const chat_user_tripcode_nbsp = document.createElement("span");
chat_user_tripcode_nbsp.classList.add("for-tripcode");
chat_user_tripcode_nbsp.innerHTML = "&nbsp;";
const chat_user_tripcode = document.createElement("span");
chat_user_tripcode.classList.add("tripcode");
chat_user_tripcode.classList.add("for-tripcode");
if (user.tripcode !== null) {
chat_user_tripcode.innerHTML = user.tripcode.digest;
}
return [chat_user_name, chat_user_tripcode_nbsp, chat_user_tripcode];
}
const create_and_add_chat_message = (object) => {
const chat_message = create_chat_message(object);
@ -142,6 +178,8 @@ const create_and_add_chat_message = (object) => {
}
let users = {};
let stats = null;
let stats_received = null;
let default_name = {true: "Broadcaster", false: "Anonymous"};
let max_chat_scrollback = 256;
const tidy_stylesheet = ({stylesheet, selector_regex, ignore_condition}) => {
@ -172,7 +210,7 @@ const update_user_colors = (token_hash=null) => {
token_hashes = token_hash === null ? Object.keys(users) : [token_hash];
const {to_delete, to_ignore} = tidy_stylesheet({
stylesheet: stylesheet_color,
selector_regex: /\.chat-message\[data-token-hash="([a-z2-7]{26})"\] > \.chat-message__name/,
selector_regex: /\[data-token-hash="([a-z2-7]{26})"\] > \.chat-name/,
ignore_condition: (this_token_hash, this_user, css_rule) => {
const irrelevant = ignore_other_token_hashes && this_token_hash !== token_hash;
const correct_color = equal(css_rule.style.color, this_user.color);
@ -184,7 +222,7 @@ const update_user_colors = (token_hash=null) => {
if (!to_ignore.has(this_token_hash)) {
const user = users[this_token_hash];
stylesheet_color.insertRule(
`.chat-message[data-token-hash="${this_token_hash}"] > .chat-message__name { color: ${user.color}; }`,
`[data-token-hash="${this_token_hash}"] > .chat-name { color: ${user.color}; }`,
stylesheet_color.cssRules.length,
);
}
@ -204,8 +242,8 @@ const update_user_names = (token_hash=null) => {
const this_token_hash = chat_message.dataset.tokenHash;
if (token_hashes.includes(this_token_hash)) {
const user = users[this_token_hash];
const chat_message_name = chat_message.querySelector(".chat-message__name");
chat_message_name.innerHTML = create_chat_message_name(user).innerHTML;
const chat_message_name = chat_message.querySelector(".chat-name");
chat_message_name.innerHTML = create_chat_user_name(user).innerHTML;
}
}
}
@ -214,7 +252,7 @@ const update_user_tripcodes = (token_hash=null) => {
token_hashes = token_hash === null ? Object.keys(users) : [token_hash];
const {to_delete: to_delete_display, to_ignore: to_ignore_display} = tidy_stylesheet({
stylesheet: stylesheet_tripcode_display,
selector_regex: /\.chat-message\[data-token-hash="([a-z2-7]{26})"\] > \.for-tripcode/,
selector_regex: /\[data-token-hash="([a-z2-7]{26})"\] > \.for-tripcode/,
ignore_condition: (this_token_hash, this_user, css_rule) => {
const irrelevant = ignore_other_token_hashes && this_token_hash !== token_hash;
const correctly_hidden = this_user.tripcode === null && css_rule.style.display === "none";
@ -224,7 +262,7 @@ const update_user_tripcodes = (token_hash=null) => {
});
const {to_delete: to_delete_colors, to_ignore: to_ignore_colors} = tidy_stylesheet({
stylesheet: stylesheet_tripcode_colors,
selector_regex: /\.chat-message\[data-token-hash="([a-z2-7]{26})"\] > \.tripcode/,
selector_regex: /\[data-token-hash="([a-z2-7]{26})"\] > \.tripcode/,
ignore_condition: (this_token_hash, this_user, css_rule) => {
const irrelevant = ignore_other_token_hashes && this_token_hash !== token_hash;
const correctly_blank = (
@ -247,26 +285,26 @@ const update_user_tripcodes = (token_hash=null) => {
if (tripcode === null) {
if (!to_ignore_display.has(token_hash)) {
stylesheet_tripcode_display.insertRule(
`.chat-message[data-token-hash="${this_token_hash}"] > .for-tripcode { display: none; }`,
`[data-token-hash="${this_token_hash}"] > .for-tripcode { display: none; }`,
stylesheet_tripcode_display.cssRules.length,
);
}
if (!to_ignore_colors.has(token_hash)) {
stylesheet_tripcode_colors.insertRule(
`.chat-message[data-token-hash="${this_token_hash}"] > .tripcode { background-color: initial; color: initial; }`,
`[data-token-hash="${this_token_hash}"] > .tripcode { background-color: initial; color: initial; }`,
stylesheet_tripcode_colors.cssRules.length,
);
}
} else {
if (!to_ignore_display.has(token_hash)) {
stylesheet_tripcode_display.insertRule(
`.chat-message[data-token-hash="${this_token_hash}"] > .for-tripcode { display: inline; }`,
`[data-token-hash="${this_token_hash}"] > .for-tripcode { display: inline; }`,
stylesheet_tripcode_display.cssRules.length,
);
}
if (!to_ignore_colors.has(token_hash)) {
stylesheet_tripcode_colors.insertRule(
`.chat-message[data-token-hash="${this_token_hash}"] > .tripcode { background-color: ${tripcode.background_color}; color: ${tripcode.foreground_color}; }`,
`[data-token-hash="${this_token_hash}"] > .tripcode { background-color: ${tripcode.background_color}; color: ${tripcode.foreground_color}; }`,
stylesheet_tripcode_colors.cssRules.length,
);
}
@ -330,7 +368,7 @@ const enable_captcha = (digest) => {
chat_form_captcha_answer.disabled = false;
chat_form_comment.required = false;
chat_form_captcha_image.removeAttribute("src");
chat_form_captcha_image.src = `/captcha.jpg?token=${encodeURIComponent(token)}&digest=${encodeURIComponent(digest)}`;
chat_form_captcha_image.src = `/captcha.jpg?token=${encodeURIComponent(TOKEN)}&digest=${encodeURIComponent(digest)}`;
chat_form_submit.disabled = false;
chat_form.dataset.captcha = "";
}
@ -353,20 +391,14 @@ const set_title = (title) => {
info_title.innerHTML = element.outerHTML;
}
let frozen_uptime = null;
let frozen_uptime_received = null;
const set_frozen_uptime = (x) => {
frozen_uptime = x;
frozen_uptime_received = new Date();
}
const update_uptime = () => {
if (frozen_uptime_received === null) {
if (stats_received === null) {
return;
} else if (frozen_uptime === null) {
} else if (stats === null) {
info_uptime.innerText = "";
} else {
const frozen_uptime_received_ago = (new Date() - frozen_uptime_received) / 1000;
const uptime = Math.round(frozen_uptime + frozen_uptime_received_ago);
const stats_received_ago = (new Date() - stats_received) / 1000;
const uptime = Math.round(stats.uptime + stats_received_ago);
const s = Math.round(uptime % 60);
const m = Math.floor(uptime / 60) % 60
@ -383,8 +415,75 @@ const update_uptime = () => {
}
setInterval(update_uptime, 1000); // always update uptime
const set_viewership = (n) => {
info_viewership.innerText = n === null ? "" : `${n} viewers`;
const update_viewership = () => {
info_viewership.innerText = stats === null ? "" : `${stats.viewership} viewers`;
}
const update_stats = () => {
if (stats === null) {
update_viewership();
update_uptime();
} else {
update_uptime();
update_viewership();
}
}
const update_users_list = () => {
listed_watching = new Set();
listed_notwatching = new Set();
// remove no-longer-known users
for (const element of chat_users_watching.querySelectorAll('.chat-user')) {
const token_hash = element.dataset.tokenHash;
if (!Object.prototype.hasOwnProperty(users, token_hash)) {
element.remove();
} else {
listed_watching.add(token_hash);
}
}
for (const element of chat_users_notwatching.querySelectorAll('.chat-user')) {
const token_hash = element.dataset.tokenHash;
if (!Object.prototype.hasOwnProperty(users, token_hash)) {
element.remove();
} else {
listed_notwatching.add(token_hash);
}
}
// add remaining watching/non-watching users
const insert = (user, token_hash, is_you, chat_users_sublist) => {
const chat_user_components = create_chat_user_components(user);
const chat_user = document.createElement("li");
chat_user.classList.add("chat-user");
chat_user.dataset.tokenHash = token_hash;
for (const chat_user_component of chat_user_components) {
chat_user.insertAdjacentElement("beforeend", chat_user_component);
}
if (is_you) {
const you = document.createElement("span");
you.innerText = " (You)";
chat_user.insertAdjacentElement("beforeend", you);
}
chat_users_sublist.insertAdjacentElement("beforeend", chat_user);
}
let watching = 0, notwatching = 0;
for (const token_hash of Object.keys(users)) {
const user = users[token_hash];
const is_you = token_hash === TOKEN_HASH;
if (user.watching === true && !listed_watching.has(token_hash)) {
insert(user, token_hash, is_you, chat_users_watching);
watching++;
}
if (user.watching === false && !listed_notwatching.has(token_hash)) {
insert(user, token_hash, is_you, chat_users_notwatching);
notwatching++;
}
}
// show correct numbers
chat_users_watching_header.innerText = `Watching (${watching})`;
chat_users_notwatching_header.innerText = `Not watching (${notwatching})`;
}
const on_websocket_message = (event) => {
@ -402,12 +501,17 @@ const on_websocket_message = (event) => {
// set title
set_title(receipt.title);
// set viewership
set_viewership(receipt.viewership);
// update stats (uptime/viewership)
stats = receipt.stats;
stats_received = new Date();
update_stats();
// set uptime
set_frozen_uptime(receipt.uptime);
update_uptime();
// stream reload button
if (stats === null || [stream.NETWORK_EMPTY, stream.NETWORK_LOADING].includes(stream.networkState)) {
info_button.removeAttribute("data-visible");
} else {
info_button.dataset.visible = "";
}
// chat form nonce
chat_form_nonce.value = receipt.nonce;
@ -435,11 +539,12 @@ const on_websocket_message = (event) => {
default_name = receipt.default;
max_chat_scrollback = receipt.scrollback;
// appearances
// update users
users = receipt.users;
update_user_names();
update_user_colors();
update_user_tripcodes();
update_users_list()
// insert new messages
const last = chat_messages.children.length == 0 ? null : chat_messages.children[chat_messages.children.length - 1];
@ -454,18 +559,26 @@ const on_websocket_message = (event) => {
case "info":
console.log("ws info", receipt);
// set title
if (receipt.title !== undefined) {
set_title(receipt.title);
}
if (receipt.uptime !== undefined) {
set_frozen_uptime(receipt.uptime);
update_uptime();
// update stats (uptime/viewership)
if (receipt.stats !== undefined) {
stats = receipt.stats;
stats_received = new Date();
update_stats();
}
if (receipt.viewership === 0 && frozen_uptime === null) {
set_viewership(null);
} else if (receipt.viewership !== undefined) {
set_viewership(receipt.viewership);
// stream reload button
if (stats === null || [stream.NETWORK_EMPTY, stream.NETWORK_LOADING].includes(stream.networkState)) {
info_button.removeAttribute("data-visible");
} else {
info_button.dataset.visible = "";
}
break;
case "ack":
@ -499,6 +612,7 @@ const on_websocket_message = (event) => {
update_user_names();
update_user_colors();
update_user_tripcodes();
update_users_list()
break;
case "rem-users":
@ -508,6 +622,7 @@ const on_websocket_message = (event) => {
}
update_user_colors();
update_user_tripcodes();
update_users_list()
break;
case "captcha":
@ -530,7 +645,7 @@ const connect_websocket = () => {
}
chat_live_ball.style.borderColor = "gold";
chat_live_status.innerHTML = "<span data-verbose='false'>Waiting...</span> <span data-verbose='true'>Connecting to chat...</span>";
ws = new WebSocket(`ws://${document.domain}:${location.port}/live?token=${encodeURIComponent(token)}`);
ws = new WebSocket(`ws://${document.domain}:${location.port}/live?token=${encodeURIComponent(TOKEN)}`);
ws.addEventListener("open", (event) => {
console.log("websocket open", event);
chat_form_submit.disabled = false;
@ -569,6 +684,19 @@ const connect_websocket = () => {
connect_websocket();
/* stream reload button */
const stream = document.getElementById("stream_js");
const info_button = document.getElementById("info_js__float__button");
info_button.addEventListener("click", (event) => {
stream.load();
info_button.removeAttribute("data-visible");
});
stream.addEventListener("error", (event) => {
if (stats !== null) {
info_button.dataset.visible = "";
}
});
/* override js-only chat form */
const chat_form = document.getElementById("chat-form_js");
const chat_form_nonce = document.getElementById("chat-form_js__nonce");

ファイルの表示

@ -30,7 +30,7 @@ body {
grid-auto-rows: var(--video-height) auto min-content 1fr auto;
grid-template-areas:
"stream"
"toggle"
"nav"
"info"
"chat"
"footer";
@ -50,10 +50,15 @@ noscript {
#stream {
background: black;
width: 100%;
height: var(--video-height);
grid-area: stream;
}
#stream_js {
width: 100%;
height: 100%;
}
#stream_nojs {
height: 100%;
}
#info {
border-top: var(--main-border);
@ -63,6 +68,7 @@ noscript {
overflow-y: auto;
padding: 0.75ch 1.25ch;
height: 100%;
box-sizing: border-box;
}
#info_js__float {
float: right;
@ -71,6 +77,9 @@ noscript {
grid-auto-flow: column;
grid-gap: 2.5ch;
}
#info_js__float__button:not([data-visible]) {
display: none;
}
#info_js__float__uptime {
font-variant-numeric: tabular-nums;
}
@ -93,15 +102,69 @@ noscript {
grid-area: chat;
height: 50vh;
min-height: 24ch;
position: relative;
}
#chat__toggle {
opacity: 0;
position: absolute;
top: calc(0.5rem + 1px);
left: calc(0.5rem + 4px);
margin: 0;
}
#chat__toggle:checked ~ #chat__body > #chat__body__messages,
#chat__toggle:not(:checked) ~ #chat__body > #chat__body__users {
visibility: hidden;
}
#chat__toggle:checked + #chat__header > #chat__header__button {
border-style: inset;
background-color: #b3b3bf;
}
#chat__toggle:checked + #chat__header > #chat__header__button:hover {
background-color: #a6a6b1;
}
#chat__toggle:focus-visible + #chat__header > #chat__header__button {
border-color: #3584e4;
box-shadow: 0 0 6px #3584e4;
}
#chat__header {
text-align: center;
padding: 0.5rem 0;
padding: 0.5rem;
border-bottom: var(--chat-border);
display: grid;
align-items: center;
}
#chat__messages {
#chat__header__button {
grid-column: 1;
grid-row: 1;
width: min-content;
z-index: 1;
padding: 1px 5px;
background-color: #c9c9d3;
border: 3px outset #8f8f9d;
border-radius: 4px;
color: black;
user-select: none;
}
#chat__header__button:hover {
background-color: #b3b3bf;
}
#chat__header__button:active {
border-style: inset;
background-color: #9999a4 !important;
}
#chat__header__text {
margin: 0;
font-weight: normal;
text-align: center;
grid-column: 1;
grid-row: 1;
}
#chat__body {
position: relative;
}
#chat__body__messages {
position: relative;
height: 100%;
}
#chat-messages_js {
list-style: none;
margin: 0;
@ -131,13 +194,13 @@ noscript {
font-size: 10pt;
cursor: help;
}
.chat-message__name {
.chat-name {
overflow-wrap: anywhere;
font-weight: bold;
/* color: attr("data-color"); */
cursor: default;
}
.chat-message__name__tag {
.chat-name__tag {
font-family: monospace;
font-size: 9pt;
vertical-align: top;
@ -153,6 +216,46 @@ noscript {
font-size: 9pt;
cursor: default;
}
#chat__body__users {
background-color: #121214;
mask-image: linear-gradient(black calc(100% - 0.625rem), transparent calc(100% - 0.125rem));
position: absolute;
top: 0;
width: 100%;
height: 100%;
display: grid;
grid-auto-rows: min-content auto;
}
#chat-users-header {
padding: 0.5rem;
background-color: #2c2c30;
border-bottom: var(--chat-border);
}
#chat-users-header > h4 {
margin: 0;
font-weight: normal;
text-align: center;
}
#chat-users_js {
padding: 0.5rem 0.75rem 0.875rem;
overflow: auto;
}
#chat-users_js__watching-header,
#chat-users_js__notwatching-header {
margin: 0;
}
#chat-users_js__watching,
#chat-users_js__notwatching {
margin: 0;
padding-left: 0.75rem;
list-style: none;
}
.chat-user {
line-height: 1.4375;
}
#chat-users_nojs {
height: 100%;
}
#chat-form_js {
display: grid;
grid-template-columns: 1fr min-content min-content 5rem;
@ -233,13 +336,13 @@ noscript {
100% {filter: brightness(100%)}
}
#toggle {
grid-area: toggle;
#nav {
grid-area: nav;
border-top: var(--main-border);
display: grid;
grid-template-columns: repeat(3, 1fr);
}
#toggle > a {
#nav > a {
text-align: center;
padding: 1ch;
font-variant: all-small-caps;
@ -266,9 +369,9 @@ footer {
#chat:target, #both:target > #chat {
display: grid;
}
#info:target ~ #toggle > [href="#info"],
#chat:target ~ #toggle > [href="#chat"],
#both:target > #toggle > [href="#both"] {
#info:target ~ #nav > [href="#info"],
#chat:target ~ #nav > [href="#chat"],
#both:target > #nav > [href="#both"] {
background-color: #3065a6;
border-style: inset;
}
@ -291,7 +394,7 @@ footer {
"info chat"
"footer chat";
}
#toggle {
#nav {
display: none;
}
#info {

ファイルの表示

@ -1,3 +1,5 @@
import itertools
import operator
import time
import aiofiles
@ -5,7 +7,7 @@ from quart import current_app
from anonstream.segments import get_playlist, Offline
from anonstream.wrappers import ttl_cache_async, with_timestamp
from anonstream.helpers.user import is_watching
from anonstream.user import get_watching_users
CONFIG = current_app.config
USERS = current_app.users
@ -35,12 +37,26 @@ def get_stream_uptime(rounded=True):
return uptime
@with_timestamp
def get_stream_viewership(timestamp):
return sum(map(lambda user: is_watching(timestamp, user), USERS))
def get_raw_viewership(timestamp):
users = get_watching_users(timestamp)
return max(
map(operator.itemgetter(0), zip(itertools.count(1), users)),
default=0,
)
def get_stream_viewership_or_none(uptime):
viewership = get_stream_viewership()
return uptime and viewership
def get_stream_uptime_and_viewership(for_websocket=False):
uptime = get_stream_uptime()
if not for_websocket:
viewership = None if uptime is None else get_raw_viewership()
result = (uptime, viewership)
elif uptime is None:
result = None
else:
result = {
'uptime': uptime,
'viewership': get_raw_viewership(),
}
return result
def is_online():
try:

ファイルの表示

@ -5,9 +5,9 @@ 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, get_stream_viewership_or_none
from anonstream.stream import is_online, get_stream_title, get_stream_uptime_and_viewership
from anonstream.user import get_sunsettable_users
from anonstream.wrappers import with_timestamp
from anonstream.helpers.user import is_visible
CONFIG = current_app.config
MESSAGES = current_app.messages
@ -46,21 +46,16 @@ async def t_sunset_users(timestamp, iteration):
if iteration == 0:
return
tokens = []
for token in USERS_BY_TOKEN:
user = USERS_BY_TOKEN[token]
if not is_visible(timestamp, MESSAGES, user):
tokens.append(token)
# Broadcast a users update, in case any users being
# removed have been mutated or are new.
broadcast_users_update()
token_hashes = []
while tokens:
token = tokens.pop()
token_hash = USERS_BY_TOKEN.pop(token)['token_hash']
token_hashes.append(token_hash)
# Broadcast a users update, in case any users being
# removed have been mutated or are new.
broadcast_users_update()
users = list(get_sunsettable_users(timestamp))
while users:
user = users.pop()
USERS_BY_TOKEN.pop(user['token'])
token_hashes.append(user['token_hash'])
if token_hashes:
broadcast(
@ -99,21 +94,22 @@ async def t_broadcast_users_update(iteration):
async def t_broadcast_stream_info_update(iteration):
if iteration == 0:
title = await get_stream_title()
uptime = get_stream_uptime()
viewership = get_stream_viewership_or_none(uptime)
uptime, viewership = get_stream_uptime_and_viewership()
current_app.stream_title = title
current_app.stream_uptime = uptime
current_app.stream_viewership = viewership
else:
payload = {}
# Check if the stream title has changed
title = await get_stream_title()
uptime, viewership = get_stream_uptime_and_viewership()
# Check if the stream title has changed
if current_app.stream_title != title:
current_app.stream_title = title
payload['title'] = title
# Check if the stream uptime has changed more or less than expected
# Check if the stream uptime has changed unexpectedly
if current_app.stream_uptime is None:
expected_uptime = None
else:
@ -121,20 +117,27 @@ async def t_broadcast_stream_info_update(iteration):
current_app.stream_uptime
+ CONFIG['TASK_PERIOD_BROADCAST_STREAM_INFO_UPDATE']
)
uptime = get_stream_uptime()
current_app.stream_uptime = uptime
if uptime is None and expected_uptime is None:
pass
stats_changed = False
elif uptime is None or expected_uptime is None:
payload['uptime'] = uptime
elif abs(uptime - expected_uptime) >= 0.0625:
payload['uptime'] = uptime
stats_changed = True
else:
stats_changed = abs(uptime - expected_uptime) >= 0.0625
# 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
stats_changed = True
if stats_changed:
if uptime is None:
payload['stats'] = None
else:
payload['stats'] = {
'uptime': uptime,
'viewership': viewership,
}
if payload:
broadcast(USERS, payload={'type': 'info', **payload})

ファイルの表示

@ -2,23 +2,36 @@
<html id="nochat">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" type="text/css">
</head>
<body id="both" data-token="{{ user.token }}">
<video id="stream" src="{{ url_for('stream', token=user.token) }}" autoplay controls></video>
<body id="both" data-token="{{ user.token }}" data-token-hash="{{ user.token_hash }}">
<article id="stream">
<noscript><iframe id="stream_nojs" name="stream_nojs" src="{{ url_for('nojs_stream', token=user.token) }}"></iframe></noscript>
</article>
<article id="info">
<noscript><iframe id="info_nojs" src="{{ url_for('nojs_info', token=user.token) }}" data-js="false"></iframe></noscript>
</article>
<aside id="chat">
<header id="chat__header">Stream chat</header>
<article id="chat__messages">
<noscript><iframe id="chat-messages_nojs" src="{{ url_for('nojs_chat', token=user.token, _anchor='end') }}" data-js="false"></iframe></noscript>
<input id="chat__toggle" type="checkbox">
<header id="chat__header">
<label id="chat__header__button" for="chat__toggle">Users</label>
<h3 id="chat__header__text">Stream chat</h3>
</header>
<article id="chat__body">
<section id="chat__body__messages">
<noscript><iframe id="chat-messages_nojs" src="{{ url_for('nojs_chat', token=user.token, _anchor='end') }}" data-js="false"></iframe></noscript>
</section>
<section id="chat__body__users">
<header id="chat-users-header"><h4>Users in chat</h4></header>
<noscript><iframe id="chat-users_nojs" src="{{ url_for('nojs_users', token=user.token) }}" data-js="false"></iframe></noscript>
</section>
</article>
<section id="chat__form">
<noscript><iframe id="chat-form_nojs" src="{{ url_for('nojs_form', token=user.token) }}" data-js="false"></iframe></noscript>
</section>
</aside>
<nav id="toggle">
<nav id="nav">
<a href="#info">info</a>
<a href="#chat">chat</a>
<a href="#both">both</a>

22
anonstream/templates/macros/user.html ノーマルファイル
ファイルの表示

@ -0,0 +1,22 @@
{%
macro appearance(
user,
name_class,
tag_class,
tripcode_nbsp_class='for-tripcode',
tripcode_class='tripcode for-tripcode'
)
%}
{{- '' -}}
<span class="{{ name_class }}" style="color:{{ user.color }};">
{{- user.name or get_default_name(user) -}}
{%- if not user.broadcaster and user.name is none -%}
<sup class="{{ tag_class }}">{{ user.tag }}</sup>
{%- endif -%}
</span>
{%- if user.tripcode -%}
<span class="{{ tripcode_nbsp_class }}">&nbsp;</span>
{{- '' -}}
<span class="{{ tripcode_class }}" style="background-color:{{ user.tripcode.background_color }};color:{{ user.tripcode.foreground_color }};">{{ user.tripcode.digest }}</span>
{%- endif -%}
{% endmacro %}

ファイルの表示

@ -1,8 +1,11 @@
{% from 'macros/user.html' import appearance with context %}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="4">
<meta http-equiv="refresh" content="5; url={{ url_for('nojs_chat_redirect', token=user.token) }}">
<style>
html {
height: 100%;
@ -32,7 +35,7 @@
text-decoration: none;
transform: rotate(-180deg);
}
#chat-timeout {
#timeout {
z-index: 1;
position: absolute;
top: 0.5rem;
@ -40,15 +43,15 @@
visibility: hidden;
animation: appear 0s {{ timeout }}s forwards;
}
#chat-timeout header {
#timeout header {
font-size: 20pt;
}
#chat-timeout-dismiss {
#timeout-dismiss {
position: absolute;
bottom: 2px;
width: calc(100% - 1rem);
}
#chat-timeout-dismiss > .button {
#timeout-dismiss > .button {
visibility: hidden;
height: 0;
padding: 0;
@ -57,12 +60,12 @@
appear 0s {{ timeout }}s forwards,
unskinny 0s {{ timeout }}s forwards;
}
#chat-timeout-alt {
#timeout-alt {
padding: 4px 0 2px 0;
}
#notimeout:target + #chat-timeout,
#notimeout:target ~ #chat-timeout-dismiss,
#notimeout:not(:target) ~ #chat-timeout-alt {
#notimeout:target + #timeout,
#notimeout:target ~ #timeout-dismiss,
#notimeout:not(:target) ~ #timeout-alt {
display: none;
}
@keyframes appear {
@ -126,8 +129,8 @@
<body>
<div id="end"></div>
<div id="notimeout"></div>
<aside id="chat-timeout">
<a class="button" href="{{ url_for('nojs_chat_redirect') }}">
<aside id="timeout">
<a class="button" href="{{ url_for('nojs_chat_redirect', token=user.token) }}">
<header>Timed out</header>
<small>Click to refresh</small>
</a>
@ -138,28 +141,18 @@
{% with user = users_by_token[message.token] %}
<time class="chat-message__time" datetime="{{ message.date }}T{{ message.time_seconds }}Z" title="{{ message.date }} {{ message.time_seconds }}">{{ message.time_minutes }}</time>
{{- '&nbsp;' | safe -}}
<span class="chat-message__name" style="color:{{ user.color }};">
{{- user.name or get_default_name(user) -}}
{%- if not user.broadcaster and user.name is none -%}
<sup class="chat-message__name__tag">{{ user.tag }}</sup>
{%- endif -%}
</span>
{%- if user.tripcode -%}
<span class="for-tripcode">&nbsp;</span>
{{- '' -}}
<span class="tripcode for-tripcode" style="background-color:{{ user.tripcode.background_color }};color:{{ user.tripcode.foreground_color }};">{{ user.tripcode.digest }}</span>
{%- endif -%}
{{ appearance(user, name_class='chat-message__name', tag_class='chat-message__name__tag') }}
{{- ':&nbsp;' | safe -}}
<span class="chat-message__markup">{{ message.markup }}</span>
{% endwith %}
</li>
{% endfor %}
</ol>
<aside id="chat-timeout-dismiss">
<aside id="timeout-dismiss">
<a class="button" href="#notimeout">Hide timeout notice</a>
</aside>
<aside id="chat-timeout-alt">
<a class="button" href="{{ url_for('nojs_chat_redirect') }}">Click to refresh</a>
<aside id="timeout-alt">
<a class="button" href="{{ url_for('nojs_chat_redirect', token=user.token) }}">Click to refresh</a>
</aside>
</body>
</html>

ファイルの表示

@ -2,6 +2,7 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
:root {
--link-color: #42a5d7;

ファイルの表示

@ -2,7 +2,8 @@
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="12">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="6">
<style>
body {
overflow-y: auto;
@ -17,6 +18,10 @@
grid-auto-flow: column;
grid-gap: 2.5ch;
}
#float__form {
display: block;
margin: 0;
}
#float__uptime {
font-variant-numeric: tabular-nums;
}
@ -31,6 +36,12 @@
<body>
{% if uptime is not none %}
<aside id="float">
{% if user.presence != Presence.WATCHING %}
<form id="float__form" action="{{ url_for('nojs_stream') }}" target="stream_nojs">
<input type="hidden" name="token" value="{{ user.token }}">
<input type="submit" value="Reload stream">
</form>
{% endif %}
<div id="float__viewership">{{ viewership }} viewers</div>
<div id="float__uptime">{{ uptime }}</div>
</aside>

24
anonstream/templates/nojs_stream.html ノーマルファイル
ファイルの表示

@ -0,0 +1,24 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
html {
height: 100%;
}
body {
height: 100%;
margin: 0;
overflow: hidden;
}
#stream {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<video id="stream" src="{{ url_for('stream', token=user.token) }}" autoplay controls></video>
</body>
</html>

111
anonstream/templates/nojs_users.html ノーマルファイル
ファイルの表示

@ -0,0 +1,111 @@
{% from 'macros/user.html' import appearance with context %}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="6">
<style>
html {
min-height: 100%;
}
body {
margin: 0;
color: #ddd;
font-family: sans-serif;
}
#timeout {
height: 0;
visibility: hidden;
animation: appear 0s {{ timeout }}s forwards;
}
#timeout > a {
display: block;
text-align: center;
background-color: #3674bf;
border: 4px outset #3584e4;
box-shadow: 0 0 5px #3584e4;
padding: 1.25ch 0;
box-sizing: border-box;
color: inherit;
font-size: 12pt;
font-weight: bold;
text-decoration: none;
animation: unskinny 0s {{ timeout }}s forwards;
}
#timeout header {
font-size: 20pt;
}
@keyframes appear {
to {
height: auto;
visibility: visible;
}
}
@keyframes unskinny {
to {
margin: 0.5rem;
}
}
#main {
margin: 0.5rem 0.75rem 0.875rem;
}
#main > h5 {
margin: 0;
}
#main > ul {
margin: 0;
padding-left: 0.75rem;
list-style: none;
}
.user {
line-height: 1.4375;
}
.user__name {
font-weight: bold;
cursor: default;
}
.user__name__tag {
font-family: monospace;
font-size: 9pt;
vertical-align: top;
}
.tripcode {
padding: 0 5px;
border-radius: 7px;
font-family: monospace;
font-size: 9pt;
cursor: default;
}
</style>
</head>
<body>
<aside id="timeout">
<a href="">
<header>Timed out</header>
<small>Click to refresh</small>
</a>
</aside>
<main id="main">
<h5>Watching ({{ users_watching | length }})</h5>
<ul>
{% for user_listed in users_watching %}
<li class="user">
{{- appearance(user_listed, name_class='user__name', tag_class='user__name__tag') -}}
{%- if user.token == user_listed.token %} (You){% endif -%}
</li>
{% endfor %}
</ul>
<br>
<h5>Not watching ({{ users_notwatching | length }})</h5>
<ul>
{% for user_listed in users_notwatching %}
<li class="user">
{{- appearance(user_listed, name_class='user__name', tag_class='user__name__tag') -}}
{%- if user.token == user_listed.token %} (You){% endif -%}
</li>
{% endfor %}
</ul>
</main>
</body>
</html>

ファイルの表示

@ -4,11 +4,11 @@ from math import inf
from quart import current_app
from anonstream.wrappers import try_except_log, with_timestamp
from anonstream.helpers.user import is_visible
from anonstream.helpers.user import get_presence, Presence
from anonstream.helpers.captcha import check_captcha_digest, Answer
from anonstream.helpers.tripcode import generate_tripcode
from anonstream.utils.colour import color_to_colour, get_contrast, NotAColor
from anonstream.utils.user import get_user_for_websocket
from anonstream.utils.user import get_user_for_websocket, trilean
CONFIG = current_app.config
MESSAGES = current_app.messages
@ -36,25 +36,30 @@ def pop_state(user, state_id):
state = None
return state
def try_change_appearance(user, name, color, password,
want_delete_tripcode, want_change_tripcode):
def try_change_appearance(user, name, color, password, want_tripcode):
errors = []
def try_(f, *args, **kwargs):
return try_except_log(errors, BadAppearance)(f)(*args, **kwargs)
try_(change_name, user, name, dry_run=True)
try_(change_color, user, color, dry_run=True)
if want_delete_tripcode:
pass
elif want_change_tripcode:
if want_tripcode:
try_(change_tripcode, user, password, dry_run=True)
if not errors:
change_name(user, name)
change_color(user, color)
if want_delete_tripcode:
# Leave tripcode
if want_tripcode is None:
pass
# Delete tripcode
elif not want_tripcode:
delete_tripcode(user)
elif want_change_tripcode:
# Change tripcode
elif want_tripcode:
change_tripcode(user, password)
# Add to the users update buffer
@ -112,13 +117,9 @@ def watched(timestamp, user):
@with_timestamp
def get_all_users_for_websocket(timestamp):
visible_users = filter(
lambda user: is_visible(timestamp, MESSAGES, user),
USERS,
)
return {
user['token_hash']: get_user_for_websocket(user)
for user in visible_users
for user in get_unsunsettable_users(timestamp)
}
def verify(user, digest, answer):
@ -153,3 +154,63 @@ def deverify(timestamp, user):
if n_user_messages >= CONFIG['FLOOD_THRESHOLD']:
user['verified'] = False
def _update_presence(timestamp, user):
old, user['presence'] = user['presence'], get_presence(timestamp, user)
if trilean(user['presence']) != trilean(old):
USERS_UPDATE_BUFFER.add(user['token'])
return user['presence']
@with_timestamp
def update_presence(timestamp, user):
return _update_presence(timestamp, user)
def get_users_and_update_presence(timestamp):
for user in USERS:
_update_presence(timestamp, user)
yield user
def get_watching_users(timestamp):
return filter(
lambda user: user['presence'] == Presence.WATCHING,
get_users_and_update_presence(timestamp),
)
def get_absent_users(timestamp):
return filter(
lambda user: user['presence'] == Presence.ABSENT,
get_users_and_update_presence(timestamp),
)
def is_sunsettable(user):
return user['presence'] == Presence.ABSENT and not has_left_messages(user)
def has_left_messages(user):
return any(
message['token'] == user['token']
for message in MESSAGES
)
def get_sunsettable_users(timestamp):
return filter(
is_sunsettable,
get_users_and_update_presence(timestamp),
)
def get_unsunsettable_users(timestamp):
return filter(
lambda user: not is_sunsettable(user),
get_users_and_update_presence(timestamp),
)
@with_timestamp
def get_users_by_presence(timestamp):
users_by_presence = {
Presence.WATCHING: [],
Presence.NOTWATCHING: [],
Presence.TENTATIVE: [],
Presence.ABSENT: [],
}
for user in get_users_and_update_presence(timestamp):
users_by_presence[user['presence']].append(user)
return users_by_presence

ファイルの表示

@ -2,17 +2,24 @@ import base64
import hashlib
import secrets
from collections import OrderedDict
from enum import Enum
from math import inf
from quart import escape, Markup
Presence = Enum(
'Presence',
names=(
'WATCHING',
'NOTWATCHING',
'TENTATIVE',
'ABSENT',
)
)
def generate_token():
return secrets.token_hex(16)
def get_user_for_websocket(user):
keys = ['broadcaster', 'name', 'color', 'tripcode', 'tag']
return {key: user[key] for key in keys}
def concatenate_for_notice(string, *tuples):
if not tuples:
return string
@ -23,3 +30,19 @@ def concatenate_for_notice(string, *tuples):
)
)
return string + markup
def trilean(presence):
match presence:
case Presence.WATCHING:
return True
case Presence.NOTWATCHING:
return False
case _:
return None
def get_user_for_websocket(user):
keys = ('broadcaster', 'name', 'color', 'tripcode', 'tag')
return {
**{key: user[key] for key in keys},
'watching': trilean(user['presence']),
}

ファイルの表示

@ -3,7 +3,7 @@ import json
from quart import current_app, websocket
from anonstream.stream import get_stream_title, get_stream_uptime, get_stream_viewership_or_none
from anonstream.stream import get_stream_title, get_stream_uptime_and_viewership
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,14 +13,11 @@ 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': uptime,
'viewership': viewership,
'stats': get_stream_uptime_and_viewership(for_websocket=True),
'messages': get_all_messages_for_websocket(),
'users': get_all_users_for_websocket(),
'default': {