コミットを比較
33 コミット
a93135e522
...
a5b66c8d5b
作成者 | SHA1 | 日付 |
---|---|---|
n9k | a5b66c8d5b | |
n9k | af9536298b | |
n9k | b113688306 | |
n9k | b288337f1d | |
n9k | 6274903fc3 | |
n9k | b62084564e | |
n9k | d9d29b6527 | |
n9k | d61b503e43 | |
n9k | 9ebcf57de5 | |
n9k | 5590fbbdbe | |
n9k | 2bb23ab4c4 | |
n9k | c103de9849 | |
n9k | bb3002ffd5 | |
n9k | 46fce9c393 | |
n9k | 2763891a4e | |
n9k | d4b0594103 | |
n9k | 6eda20a244 | |
n9k | edddbf00bc | |
n9k | 7962de87e3 | |
n9k | ba90e18e30 | |
n9k | a970368ee6 | |
n9k | 84ec253001 | |
n9k | bfa77b738d | |
n9k | 2b1cf7d7b0 | |
n9k | 1b26ddb816 | |
n9k | 3583005123 | |
n9k | 8589216bf1 | |
n9k | 3016705783 | |
n9k | da6e0352b8 | |
n9k | a3b18bdc9f | |
n9k | c36d2b2c38 | |
n9k | 8b4d6e8c09 | |
n9k | 8d1f273a99 |
|
@ -1,3 +1,4 @@
|
|||
__pycache__/
|
||||
stream/
|
||||
*~
|
||||
title.txt
|
||||
|
|
16
README.md
16
README.md
|
@ -1,3 +1,15 @@
|
|||
# onion-livestreaming
|
||||
# anonstream
|
||||
|
||||
Recipe for livestreaming over the Tor network
|
||||
Recipe for livestreaming over Tor
|
||||
|
||||
## Repo
|
||||
|
||||
The canonical location of this repo is https://git.076.ne.jp/ninya9k/anonstream.
|
||||
|
||||
These mirrors also exist:
|
||||
* https://gitlab.com/ninya9k/anonstream
|
||||
* https://github.com/ninya9k/anonstream
|
||||
|
||||
## Credits
|
||||
|
||||
* [setting](https://thenounproject.com/icon/setting-685325/) ([/anonstream/static/settings.svg](https://git.076.ne.jp/ninya9k/anonstream/src/branch/master/anonstream/static/settings.svg)) by [ulimicon](https://thenounproject.com/unlimicon/) is licensed under [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/).
|
||||
|
|
|
@ -20,7 +20,12 @@ def create_app(config_file):
|
|||
print('Broadcaster password:', auth_password)
|
||||
|
||||
app = Quart('anonstream')
|
||||
app.jinja_options.update({
|
||||
'trim_blocks': True,
|
||||
'lstrip_blocks': True,
|
||||
})
|
||||
app.config.update({
|
||||
'SECRET_KEY_STRING': config['secret_key'],
|
||||
'SECRET_KEY': config['secret_key'].encode(),
|
||||
'AUTH_USERNAME': config['auth']['username'],
|
||||
'AUTH_PWHASH': auth_pwhash,
|
||||
|
@ -32,6 +37,8 @@ def create_app(config_file):
|
|||
'SEGMENT_SEARCH_COOLDOWN': config['segments']['search_cooldown'],
|
||||
'SEGMENT_SEARCH_TIMEOUT': config['segments']['search_timeout'],
|
||||
'SEGMENT_STREAM_INITIAL_BUFFER': config['segments']['stream_initial_buffer'],
|
||||
'STREAM_TITLE': config['title']['file'],
|
||||
'STREAM_TITLE_CACHE_LIFETIME': config['title']['file_cache_lifetime'],
|
||||
'DEFAULT_HOST_NAME': config['names']['broadcaster'],
|
||||
'DEFAULT_ANON_NAME': config['names']['anonymous'],
|
||||
'MAX_STATES': config['memory']['states'],
|
||||
|
@ -41,6 +48,7 @@ def create_app(config_file):
|
|||
'TASK_PERIOD_ROTATE_USERS': config['tasks']['rotate_users'],
|
||||
'TASK_PERIOD_ROTATE_CAPTCHAS': config['tasks']['rotate_captchas'],
|
||||
'TASK_PERIOD_BROADCAST_USERS_UPDATE': config['tasks']['broadcast_users_update'],
|
||||
'TASK_PERIOD_BROADCAST_STREAM_INFO_UPDATE': config['tasks']['broadcast_stream_info_update'],
|
||||
'THRESHOLD_USER_NOTWATCHING': config['thresholds']['user_notwatching'],
|
||||
'THRESHOLD_USER_TENTATIVE': config['thresholds']['user_tentative'],
|
||||
'THRESHOLD_USER_ABSENT': config['thresholds']['user_absent'],
|
||||
|
@ -49,6 +57,7 @@ def create_app(config_file):
|
|||
'CHAT_NAME_MAX_LENGTH': config['chat']['max_name_length'],
|
||||
'CHAT_NAME_MIN_CONTRAST': config['chat']['min_name_contrast'],
|
||||
'CHAT_BACKGROUND_COLOUR': color_to_colour(config['chat']['background_color']),
|
||||
'CHAT_LEGACY_TRIPCODE_ALGORITHM': config['chat']['legacy_tripcode_algorithm'],
|
||||
'FLOOD_DURATION': config['flood']['duration'],
|
||||
'FLOOD_THRESHOLD': config['flood']['threshold'],
|
||||
'CAPTCHA_LIFETIME': config['captcha']['lifetime'],
|
||||
|
@ -80,8 +89,13 @@ def create_app(config_file):
|
|||
app.captcha_factory = create_captcha_factory(app.config['CAPTCHA_FONTS'])
|
||||
app.captcha_signer = create_captcha_signer(app.config['SECRET_KEY'])
|
||||
|
||||
# State for tasks
|
||||
app.users_update_buffer = set()
|
||||
app.stream_title = None
|
||||
app.stream_uptime = None
|
||||
app.stream_viewership = None
|
||||
|
||||
# Background tasks' asyncio.sleep tasks, cancelled on shutdown
|
||||
app.background_sleep = set()
|
||||
|
||||
@app.after_serving
|
||||
|
|
|
@ -11,19 +11,27 @@ CONFIG = current_app.config
|
|||
def _generate_tripcode_digest_legacy(password):
|
||||
hexdigest, _ = werkzeug.security._hash_internal(
|
||||
'pbkdf2:sha256:150000',
|
||||
CONFIG['SECRET_KEY'],
|
||||
CONFIG['SECRET_KEY_STRING'],
|
||||
password,
|
||||
)
|
||||
digest = bytes.fromhex(hexdigest)
|
||||
return base64.b64encode(digest)[:8].decode()
|
||||
|
||||
def generate_tripcode_digest(password):
|
||||
def _generate_tripcode_digest(password):
|
||||
parts = CONFIG['SECRET_KEY'] + b'tripcode\0' + password.encode()
|
||||
digest = hashlib.sha256(parts).digest()
|
||||
return base64.b64encode(digest)[:8].decode()
|
||||
|
||||
def generate_tripcode(password, generate_digest=generate_tripcode_digest):
|
||||
digest = generate_digest(password)
|
||||
def generate_tripcode_digest(password):
|
||||
algorithm = (
|
||||
_generate_tripcode_digest_legacy
|
||||
if CONFIG['CHAT_LEGACY_TRIPCODE_ALGORITHM'] else
|
||||
_generate_tripcode_digest
|
||||
)
|
||||
return algorithm(password)
|
||||
|
||||
def generate_tripcode(password):
|
||||
digest = generate_tripcode_digest(password)
|
||||
background_colour = generate_colour(
|
||||
seed='tripcode-background\0' + digest,
|
||||
bg=CONFIG['CHAT_BACKGROUND_COLOUR'],
|
||||
|
|
|
@ -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,17 +66,3 @@ def get_presence(timestamp, user):
|
|||
return Presence.TENTATIVE
|
||||
|
||||
return Presence.ABSENT
|
||||
|
||||
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,8 +2,8 @@ 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
|
||||
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
|
||||
|
@ -13,20 +13,33 @@ 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,
|
||||
title=get_stream_title(),
|
||||
viewership=viewership,
|
||||
uptime=uptime,
|
||||
title=await get_stream_title(),
|
||||
Presence=Presence,
|
||||
)
|
||||
|
||||
@current_app.route('/chat/messages.html')
|
||||
@with_user_from(request)
|
||||
async def nojs_chat(user):
|
||||
async def nojs_chat_messages(user):
|
||||
return await render_template_with_etag(
|
||||
'nojs_chat.html',
|
||||
'nojs_chat_messages.html',
|
||||
user=user,
|
||||
users_by_token=USERS_BY_TOKEN,
|
||||
messages=get_scrollback(current_app.messages),
|
||||
|
@ -36,17 +49,30 @@ 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'))
|
||||
async def nojs_chat_messages_redirect(user):
|
||||
return redirect(url_for('nojs_chat_messages', token=user['token'], _anchor='end'))
|
||||
|
||||
@current_app.route('/chat/users.html')
|
||||
@with_user_from(request)
|
||||
async def nojs_chat_users(user):
|
||||
users_by_presence = get_users_by_presence()
|
||||
return await render_template_with_etag(
|
||||
'nojs_chat_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)
|
||||
async def nojs_form(user):
|
||||
async def nojs_chat_form(user):
|
||||
state_id = request.args.get('state', type=int)
|
||||
state = pop_state(user, state_id)
|
||||
prefer_chat_form = request.args.get('landing') != 'appearance'
|
||||
return await render_template(
|
||||
'nojs_form.html',
|
||||
'nojs_chat_form.html',
|
||||
user=user,
|
||||
state=state,
|
||||
prefer_chat_form=prefer_chat_form,
|
||||
|
@ -57,7 +83,7 @@ async def nojs_form(user):
|
|||
|
||||
@current_app.post('/chat/form')
|
||||
@with_user_from(request)
|
||||
async def nojs_form_redirect(user):
|
||||
async def nojs_chat_form_redirect(user):
|
||||
comment = (await request.form).get('comment', '')
|
||||
if len(comment) > CONFIG['CHAT_COMMENT_MAX_LENGTH']:
|
||||
comment = ''
|
||||
|
@ -67,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_chat_form', token=user['token'], state=state_id))
|
||||
|
||||
@current_app.post('/chat/message')
|
||||
@with_user_from(request)
|
||||
|
@ -87,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,
|
||||
|
@ -97,11 +123,12 @@ 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',
|
||||
'nojs_chat_form',
|
||||
token=user['token'],
|
||||
landing='chat',
|
||||
state=state_id,
|
||||
|
@ -111,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)
|
||||
|
||||
if len(name) == 0 or name == get_default_name(user):
|
||||
# Collect form data
|
||||
name = form.get('name', '').strip()
|
||||
if len(name) == 0:
|
||||
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
|
||||
|
@ -137,7 +165,7 @@ async def nojs_submit_appearance(user):
|
|||
|
||||
state_id = add_state(user, notice=notice, verbose=len(errors) > 1)
|
||||
return redirect(url_for(
|
||||
'nojs_form',
|
||||
'nojs_chat_form',
|
||||
token=user['token'],
|
||||
landing='appearance' if errors else 'chat',
|
||||
state=state_id,
|
||||
|
|
|
@ -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,28 +1,63 @@
|
|||
/* 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_info = '<div id="info_js"></div>';
|
||||
const jsmarkup_info_title = '<header id="info_js__title" data-js="true"></header>';
|
||||
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="">
|
||||
<textarea id="chat-form_js__comment" name="comment" maxlength="512" required placeholder="Send a message..." rows="1"></textarea>
|
||||
<textarea id="chat-form_js__comment" name="comment" maxlength="512" required placeholder="Send a message..." rows="1" autofocus></textarea>
|
||||
<div id="chat-live">
|
||||
<span id="chat-live__ball"></span>
|
||||
<span id="chat-live__status"><span>Not connected<span data-verbose='true'> to chat</span></span></span>
|
||||
<span id="chat-live__status">
|
||||
<span data-verbose="true">Not connected to chat</span>
|
||||
<span data-verbose="false">×</span>
|
||||
</span>
|
||||
</div>
|
||||
<input id="chat-form_js__captcha-digest" type="hidden" name="captcha-digest" disabled>
|
||||
<img id="chat-form_js__captcha-image" width="72" height="30">
|
||||
<input id="chat-form_js__captcha-image" type="image" width="72" height="30">
|
||||
<input id="chat-form_js__captcha-answer" name="captcha-answer" placeholder="Captcha" disabled>
|
||||
<input id="chat-form_js__settings" type="image" src="/static/settings.svg" width="28" height="28" alt="Settings">
|
||||
<input id="chat-form_js__submit" type="submit" value="Chat" accesskey="p" disabled>
|
||||
<article id="chat-form_js__notice">
|
||||
<button id="chat-form_js__notice__button" type="button">
|
||||
<header id="chat-form_js__notice__button__header"></header>
|
||||
<small>Click to dismiss</small>
|
||||
</button>
|
||||
</article>
|
||||
</form>
|
||||
<form id="appearance-form_js" data-hidden="">
|
||||
<span id="appearance-form_js__label-name">Name:</span>
|
||||
<input id="appearance-form_js__name" name="name" maxlength="24">
|
||||
<input id="appearance-form_js__color" type="color" name="color">
|
||||
<span id="appearance-form_js__label-tripcode">Tripcode:</span>
|
||||
<input id="appearance-form_js__password" type="password" name="password" placeholder="(tripcode password)" maxlength="1024">
|
||||
<div id="appearance-form_js__row">
|
||||
<article id="appearance-form_js__row__result"></article>
|
||||
<input id="appearance-form_js__row__submit" type="submit" value="Update">
|
||||
</div>
|
||||
</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);
|
||||
|
@ -35,16 +70,40 @@ const insert_jsmarkup = () => {
|
|||
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);
|
||||
}
|
||||
if (document.getElementById("info_js__float") === null) {
|
||||
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);
|
||||
}
|
||||
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");
|
||||
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) {
|
||||
|
@ -58,9 +117,52 @@ const stylesheet_color = document.styleSheets[1];
|
|||
const stylesheet_tripcode_display = document.styleSheets[2];
|
||||
const stylesheet_tripcode_colors = document.styleSheets[3];
|
||||
|
||||
/* override chat form notice button */
|
||||
const chat_form = document.getElementById("chat-form_js");
|
||||
const chat_form_notice_button = document.getElementById("chat-form_js__notice__button");
|
||||
const chat_form_notice_header = document.getElementById("chat-form_js__notice__button__header");
|
||||
chat_form_notice_button.addEventListener("click", (event) => {
|
||||
chat_form.removeAttribute("data-notice");
|
||||
chat_form_notice_header.innerText = "";
|
||||
});
|
||||
const show_notice = (text) => {
|
||||
chat_form_notice_header.innerText = text;
|
||||
chat_form.dataset.notice = "";
|
||||
}
|
||||
|
||||
/* override chat form settings input */
|
||||
const chat_appearance_form = document.getElementById("appearance-form_js");
|
||||
const chat_appearance_form_result = document.getElementById("appearance-form_js__row__result");
|
||||
const chat_form_settings = document.getElementById("chat-form_js__settings");
|
||||
chat_form_settings.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
if (chat_appearance_form.dataset.hidden === undefined) {
|
||||
chat_appearance_form.dataset.hidden = "";
|
||||
chat_form_settings.style.backgroundColor = "";
|
||||
chat_appearance_form_result.innerText = "";
|
||||
if (!chat_appearance_form_submit.disabled) {
|
||||
chat_appearance_form.reset();
|
||||
}
|
||||
} else {
|
||||
chat_appearance_form.removeAttribute("data-hidden");
|
||||
chat_form_settings.style.backgroundColor = "#4f4f53";
|
||||
}
|
||||
});
|
||||
|
||||
/* appearance form */
|
||||
const chat_appearance_form_name = document.getElementById("appearance-form_js__name");
|
||||
const chat_appearance_form_color = document.getElementById("appearance-form_js__color");
|
||||
const chat_appearance_form_password = document.getElementById("appearance-form_js__password");
|
||||
|
||||
/* create websocket */
|
||||
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];
|
||||
|
@ -76,18 +178,7 @@ 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 = " ";
|
||||
|
||||
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_user_components = create_chat_user_components(user);
|
||||
|
||||
const chat_message_markup = document.createElement("span");
|
||||
chat_message_markup.classList.add("chat-message__markup");
|
||||
|
@ -95,26 +186,57 @@ const create_chat_message = (object) => {
|
|||
|
||||
chat_message.insertAdjacentElement("beforeend", chat_message_time);
|
||||
chat_message.insertAdjacentHTML("beforeend", " ");
|
||||
chat_message.insertAdjacentElement("beforeend", chat_message_name);
|
||||
chat_message.insertAdjacentElement("beforeend", chat_message_tripcode_nbsp);
|
||||
chat_message.insertAdjacentElement("beforeend", chat_message_tripcode);
|
||||
chat_message.insertAdjacentHTML("beforeend", ": ");
|
||||
for (const chat_message_user_component of chat_message_user_components) {
|
||||
chat_message.insertAdjacentElement("beforeend", chat_message_user_component);
|
||||
}
|
||||
chat_message.insertAdjacentHTML("beforeend", ": ");
|
||||
chat_message.insertAdjacentElement("beforeend", chat_message_markup);
|
||||
|
||||
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 b = document.createElement("b");
|
||||
b.innerText = user.tag;
|
||||
const chat_user_name_tag = document.createElement("sup");
|
||||
chat_user_name_tag.classList.add("chat-name__tag");
|
||||
chat_user_name_tag.innerHTML = b.outerHTML;
|
||||
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 = " ";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let result;
|
||||
if (!user.broadcaster) {
|
||||
result = [];
|
||||
} else {
|
||||
const chat_user_insignia = document.createElement("b");
|
||||
chat_user_insignia.classList.add("chat-insignia")
|
||||
chat_user_insignia.title = "Broadcaster";
|
||||
chat_user_insignia.innerText = "##";
|
||||
const chat_user_insignia_nbsp = document.createElement("span");
|
||||
chat_user_insignia_nbsp.innerHTML = " "
|
||||
result = [chat_user_insignia, chat_user_insignia_nbsp];
|
||||
}
|
||||
result.push(...[chat_user_name, chat_user_tripcode_nbsp, chat_user_tripcode]);
|
||||
return result;
|
||||
}
|
||||
const create_and_add_chat_message = (object) => {
|
||||
const chat_message = create_chat_message(object);
|
||||
|
@ -125,6 +247,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}) => {
|
||||
|
@ -155,7 +279,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);
|
||||
|
@ -167,7 +291,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,
|
||||
);
|
||||
}
|
||||
|
@ -187,8 +311,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -197,7 +321,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";
|
||||
|
@ -207,7 +331,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 = (
|
||||
|
@ -228,28 +352,28 @@ const update_user_tripcodes = (token_hash=null) => {
|
|||
for (const this_token_hash of token_hashes) {
|
||||
const tripcode = users[this_token_hash].tripcode;
|
||||
if (tripcode === null) {
|
||||
if (!to_ignore_display.has(token_hash)) {
|
||||
if (!to_ignore_display.has(this_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)) {
|
||||
if (!to_ignore_colors.has(this_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)) {
|
||||
if (!to_ignore_display.has(this_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)) {
|
||||
if (!to_ignore_colors.has(this_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,
|
||||
);
|
||||
}
|
||||
|
@ -279,13 +403,31 @@ const chat_form_captcha_digest = document.getElementById("chat-form_js__captcha-
|
|||
const chat_form_captcha_image = document.getElementById("chat-form_js__captcha-image");
|
||||
const chat_form_captcha_answer = document.getElementById("chat-form_js__captcha-answer");
|
||||
chat_form_captcha_image.addEventListener("loadstart", (event) => {
|
||||
chat_form_captcha_image.removeAttribute("title");
|
||||
chat_form_captcha_image.removeAttribute("data-reloadable");
|
||||
chat_form_captcha_image.alt = "Loading...";
|
||||
});
|
||||
chat_form_captcha_image.addEventListener("load", (event) => {
|
||||
chat_form_captcha_image.removeAttribute("alt");
|
||||
chat_form_captcha_image.dataset.reloadable = "";
|
||||
chat_form_captcha_image.title = "Click for a new captcha";
|
||||
});
|
||||
chat_form_captcha_image.addEventListener("error", (event) => {
|
||||
chat_form_captcha_image.alt = "Captcha failed to load";
|
||||
chat_form_captcha_image.dataset.reloadable = "";
|
||||
chat_form_captcha_image.title = "Click for a new captcha";
|
||||
});
|
||||
chat_form_captcha_image.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
if (chat_form_captcha_image.dataset.reloadable !== undefined) {
|
||||
chat_form_submit.disabled = true;
|
||||
chat_form_captcha_image.alt = "Waiting...";
|
||||
chat_form_captcha_image.removeAttribute("title");
|
||||
chat_form_captcha_image.removeAttribute("data-reloadable");
|
||||
chat_form_captcha_image.removeAttribute("src");
|
||||
const payload = {type: "captcha"};
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
});
|
||||
const enable_captcha = (digest) => {
|
||||
chat_form_captcha_digest.value = digest;
|
||||
|
@ -295,7 +437,8 @@ 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 = "";
|
||||
}
|
||||
const disable_captcha = () => {
|
||||
|
@ -306,26 +449,150 @@ const disable_captcha = () => {
|
|||
chat_form_captcha_digest.value = "";
|
||||
chat_form_captcha_answer.value = "";
|
||||
chat_form_captcha_answer.required = false;
|
||||
chat_form_submit.disabled = false;
|
||||
chat_form_captcha_image.removeAttribute("alt");
|
||||
chat_form_captcha_image.removeAttribute("src");
|
||||
}
|
||||
|
||||
const set_title = (title) => {
|
||||
const h1 = document.createElement("h1");
|
||||
h1.innerText = title.replaceAll(/\r?\n/g, " ");
|
||||
info_title.innerHTML = h1.outerHTML;
|
||||
}
|
||||
|
||||
const update_uptime = () => {
|
||||
if (stats_received === null) {
|
||||
return;
|
||||
} else if (stats === null) {
|
||||
info_uptime.innerText = "";
|
||||
} else {
|
||||
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
|
||||
const h = Math.floor(uptime / 3600);
|
||||
|
||||
const ss = s.toString().padStart(2, "0");
|
||||
if (uptime < 3600) {
|
||||
info_uptime.innerText = `${m}:${ss}`;
|
||||
} else {
|
||||
const mm = m.toString().padStart(2, "0");
|
||||
info_uptime.innerText = `${h}:${mm}:${ss}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
setInterval(update_uptime, 1000); // always update uptime
|
||||
|
||||
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) => {
|
||||
//console.log("websocket message", event);
|
||||
const receipt = JSON.parse(event.data);
|
||||
switch (receipt.type) {
|
||||
case "error":
|
||||
console.log("ws error", receipt);
|
||||
chat_form_submit.disabled = false;
|
||||
chat_appearance_form_submit.disabled = false;
|
||||
break;
|
||||
|
||||
case "init":
|
||||
console.log("ws init", receipt);
|
||||
|
||||
info_title.innerText = receipt.title;
|
||||
// set title
|
||||
set_title(receipt.title);
|
||||
|
||||
// update stats (uptime/viewership)
|
||||
stats = receipt.stats;
|
||||
stats_received = new Date();
|
||||
update_stats();
|
||||
|
||||
// stream reload button
|
||||
if (stats === null || stream.networkState === stream.NETWORK_LOADING) {
|
||||
info_button.removeAttribute("data-visible");
|
||||
} else {
|
||||
info_button.dataset.visible = "";
|
||||
}
|
||||
|
||||
// chat form nonce
|
||||
chat_form_nonce.value = receipt.nonce;
|
||||
|
||||
// chat form captcha digest
|
||||
receipt.digest === null ? disable_captcha() : enable_captcha(receipt.digest);
|
||||
|
||||
// chat form submit button
|
||||
chat_form_submit.disabled = false;
|
||||
|
||||
// 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) {
|
||||
|
@ -338,13 +605,26 @@ const on_websocket_message = (event) => {
|
|||
chat_message.remove();
|
||||
}
|
||||
|
||||
// settings
|
||||
default_name = receipt.default;
|
||||
max_chat_scrollback = receipt.scrollback;
|
||||
|
||||
// update users
|
||||
users = receipt.users;
|
||||
update_user_names();
|
||||
update_user_colors();
|
||||
update_user_tripcodes();
|
||||
update_users_list()
|
||||
|
||||
// appearance form default values
|
||||
const user = users[TOKEN_HASH];
|
||||
if (user.name !== null) {
|
||||
chat_appearance_form_name.setAttribute("value", user.name);
|
||||
}
|
||||
chat_appearance_form_name.setAttribute("placeholder", default_name[user.broadcaster]);
|
||||
chat_appearance_form_color.setAttribute("value", user.color);
|
||||
|
||||
// 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) {
|
||||
|
@ -355,13 +635,37 @@ const on_websocket_message = (event) => {
|
|||
|
||||
break;
|
||||
|
||||
case "title":
|
||||
console.log("ws title", receipt);
|
||||
info_title.innerText = receipt.title;
|
||||
case "info":
|
||||
console.log("ws info", receipt);
|
||||
|
||||
// set title
|
||||
if (receipt.title !== undefined) {
|
||||
set_title(receipt.title);
|
||||
}
|
||||
|
||||
// update stats (uptime/viewership)
|
||||
if (receipt.stats !== undefined) {
|
||||
stats = receipt.stats;
|
||||
stats_received = new Date();
|
||||
update_stats();
|
||||
}
|
||||
|
||||
// stream reload button
|
||||
if (stats === null || stream.networkState === stream.NETWORK_LOADING) {
|
||||
info_button.removeAttribute("data-visible");
|
||||
} else {
|
||||
info_button.dataset.visible = "";
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "ack":
|
||||
console.log("ws ack", receipt);
|
||||
|
||||
if (receipt.notice !== null) {
|
||||
show_notice(receipt.notice);
|
||||
}
|
||||
|
||||
const existing_nonce = chat_form_nonce.value;
|
||||
if (receipt.clear && receipt.nonce === existing_nonce) {
|
||||
chat_form_comment.value = "";
|
||||
|
@ -371,6 +675,7 @@ const on_websocket_message = (event) => {
|
|||
chat_form_nonce.value = receipt.next;
|
||||
receipt.digest === null ? disable_captcha() : enable_captcha(receipt.digest);
|
||||
chat_form_submit.disabled = false;
|
||||
|
||||
break;
|
||||
|
||||
case "message":
|
||||
|
@ -391,6 +696,7 @@ const on_websocket_message = (event) => {
|
|||
update_user_names();
|
||||
update_user_colors();
|
||||
update_user_tripcodes();
|
||||
update_users_list()
|
||||
break;
|
||||
|
||||
case "rem-users":
|
||||
|
@ -400,6 +706,47 @@ const on_websocket_message = (event) => {
|
|||
}
|
||||
update_user_colors();
|
||||
update_user_tripcodes();
|
||||
update_users_list()
|
||||
break;
|
||||
|
||||
case "captcha":
|
||||
console.log("ws captcha", receipt);
|
||||
receipt.digest === null ? disable_captcha() : enable_captcha(receipt.digest);
|
||||
break;
|
||||
|
||||
case "appearance":
|
||||
console.log("ws appearance", receipt);
|
||||
|
||||
if (receipt.errors === undefined) {
|
||||
if (receipt.name !== null) {
|
||||
chat_appearance_form_name.setAttribute("value", receipt.name);
|
||||
}
|
||||
chat_appearance_form_color.setAttribute("value", receipt.color);
|
||||
chat_appearance_form_result.innerHTML = receipt.result;
|
||||
} else {
|
||||
const ul = document.createElement("ul");
|
||||
for (const error of receipt.errors) {
|
||||
const li = document.createElement("li");
|
||||
li.innerText = error[0];
|
||||
for (const tuple of error.slice(1)) {
|
||||
const mark = document.createElement("mark");
|
||||
mark.innerText = tuple[0];
|
||||
li.insertAdjacentText("beforeend", " ");
|
||||
li.insertAdjacentElement("beforeend", mark);
|
||||
li.insertAdjacentText("beforeend", tuple[1]);
|
||||
}
|
||||
ul.insertAdjacentElement("beforeend", li);
|
||||
}
|
||||
const result = document.createElement("div");
|
||||
result.innerText = "Errors:";
|
||||
result.insertAdjacentElement("beforeend", ul);
|
||||
chat_appearance_form_result.innerHTML = result.innerHTML;
|
||||
}
|
||||
|
||||
chat_appearance_form_submit.disabled = false;
|
||||
chat_appearance_form.removeAttribute("data-hidden");
|
||||
chat_form_settings.style.backgroundColor = "#4f4f53";
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
|
@ -416,13 +763,13 @@ const connect_websocket = () => {
|
|||
return;
|
||||
}
|
||||
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)}`);
|
||||
chat_live_status.innerHTML = "<span data-verbose='true'>Connecting to chat...</span><span data-verbose='false'>···</span>";
|
||||
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;
|
||||
chat_live_ball.style.borderColor = "green";
|
||||
chat_live_status.innerHTML = "<span>Connected<span data-verbose='true'> to chat</span></span>";
|
||||
chat_live_status.innerHTML = "<span><span data-verbose='true'>Connected to chat</span><span data-verbose='false'>✓</span></span>";
|
||||
// When the server is offline, a newly opened websocket can take a second
|
||||
// to close. This timeout tries to ensure the backoff doesn't instantly
|
||||
// (erroneously) reset to 2 seconds in that case.
|
||||
|
@ -438,7 +785,7 @@ const connect_websocket = () => {
|
|||
console.log("websocket close", event);
|
||||
chat_form_submit.disabled = true;
|
||||
chat_live_ball.style.borderColor = "maroon";
|
||||
chat_live_status.innerHTML = "<span data-verbose='false'>Failed to connect</span> <span data-verbose='true'>Disconnected from chat</span>";
|
||||
chat_live_status.innerHTML = "<span data-verbose='true'>Disconnected from chat</span><span data-verbose='false'>×</span>";
|
||||
if (!ws.successor) {
|
||||
ws.successor = true;
|
||||
setTimeout(connect_websocket, websocket_backoff);
|
||||
|
@ -456,18 +803,43 @@ 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");
|
||||
const chat_form_comment = document.getElementById("chat-form_js__comment");
|
||||
const chat_form_submit = document.getElementById("chat-form_js__submit");
|
||||
chat_form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const payload = Object.fromEntries(new FormData(chat_form));
|
||||
const form = Object.fromEntries(new FormData(chat_form));
|
||||
const payload = {type: "message", form: form};
|
||||
chat_form_submit.disabled = true;
|
||||
ws.send(JSON.stringify(payload));
|
||||
});
|
||||
|
||||
/* override js-only appearance form */
|
||||
const chat_appearance_form_submit = document.getElementById("appearance-form_js__row__submit");
|
||||
chat_appearance_form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const form = Object.fromEntries(new FormData(chat_appearance_form));
|
||||
const payload = {type: "appearance", form: form};
|
||||
chat_appearance_form_submit.disabled = true;
|
||||
chat_appearance_form_password.value = "";
|
||||
chat_appearance_form_result.innerText = "";
|
||||
ws.send(JSON.stringify(payload));
|
||||
});
|
||||
|
||||
/* when chat is being resized, peg its bottom in place (instead of its top) */
|
||||
const track_scroll = (element) => {
|
||||
chat_messages.dataset.scrollTop = chat_messages.scrollTop;
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="243.55pt" height="243.55pt" version="1.1" viewBox="0 0 243.55 243.55" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m104.65 0-8.2461 29.598c-7.6914 2.1172-14.992 5.2305-21.777 9.0898l-27.062-15.012-23.891 23.891 15.012 27.062c-3.8594 6.7852-6.9727 14.086-9.0898 21.777l-29.598 8.2461v34.25l29.598 8.2461c2.1172 7.6914 5.2305 14.992 9.0898 21.777l-15.012 27.062 23.891 23.891 27.062-15.012c6.7852 3.8594 14.086 6.9727 21.777 9.0898l8.2461 29.598h34.25l8.2461-29.598c7.6914-2.1172 14.992-5.2305 21.777-9.0898l27.062 15.012 23.891-23.891-15.012-27.062c3.8594-6.7812 6.9727-14.086 9.0898-21.777l29.598-8.2461v-34.25l-29.598-8.2461c-2.1172-7.6914-5.2305-14.992-9.0898-21.777l15.012-27.062-23.891-23.891-27.062 15.012c-6.7852-3.8594-14.086-6.9727-21.777-9.0898l-8.2461-29.598zm17.125 74.418c26.156 0 47.359 21.203 47.359 47.359s-21.203 47.359-47.359 47.359-47.359-21.203-47.359-47.359 21.203-47.359 47.359-47.359z" fill="#bbbbbf"/>
|
||||
</svg>
|
変更後 幅: | 高さ: | サイズ: 984 B |
|
@ -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);
|
||||
|
@ -61,11 +66,27 @@ noscript {
|
|||
}
|
||||
#info_js {
|
||||
overflow-y: auto;
|
||||
padding: 1ch 1.5ch;
|
||||
padding: 0.75ch 1.25ch;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#info_js__title {
|
||||
#info_js__float {
|
||||
float: right;
|
||||
font-size: 11pt;
|
||||
display: grid;
|
||||
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;
|
||||
}
|
||||
#info_js__title > h1 {
|
||||
margin: 0;
|
||||
font-size: 18pt;
|
||||
line-height: 1.125;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#info_nojs {
|
||||
|
@ -81,15 +102,67 @@ noscript {
|
|||
grid-area: chat;
|
||||
height: 50vh;
|
||||
min-height: 24ch;
|
||||
position: relative;
|
||||
}
|
||||
#chat__toggle {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
#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;
|
||||
|
@ -117,15 +190,17 @@ noscript {
|
|||
.chat-message__time {
|
||||
color: #b2b2b3;
|
||||
font-size: 10pt;
|
||||
}
|
||||
.chat-insignia {
|
||||
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;
|
||||
|
@ -141,18 +216,59 @@ 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 {
|
||||
position: relative;
|
||||
}
|
||||
#chat-form_js {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr min-content min-content 5rem;
|
||||
grid-template-columns: 1fr min-content min-content min-content 5rem;
|
||||
grid-template-rows: auto var(--button-height);
|
||||
grid-gap: 0.375rem;
|
||||
margin: 0 0.5rem 0.5rem 0.5rem;
|
||||
}
|
||||
#chat-form_js__submit {
|
||||
grid-column: 2 / span 1;
|
||||
padding: 0 0.5rem 0.5rem 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
#chat-form_js__comment {
|
||||
grid-column: 1 / span 4;
|
||||
grid-column: 1 / span 5;
|
||||
background-color: #434347;
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
|
@ -175,19 +291,106 @@ noscript {
|
|||
color: inherit;
|
||||
font-size: 8pt;
|
||||
}
|
||||
#chat-form_js__captcha-image[data-reloadable] {
|
||||
cursor: pointer;
|
||||
}
|
||||
#chat-form_js__captcha-answer {
|
||||
width: 8ch;
|
||||
}
|
||||
#chat-form_js__submit {
|
||||
#chat-form_js__settings {
|
||||
align-self: center;
|
||||
padding: 5px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 3px;
|
||||
color: var(--text-color);
|
||||
grid-column: 4;
|
||||
}
|
||||
#chat-form_js__settings:hover {
|
||||
background-color: #434347;
|
||||
}
|
||||
#chat-form_js__submit {
|
||||
grid-column: 5;
|
||||
}
|
||||
#chat-form_js:not([data-captcha]) > #chat-form_js__captcha-image,
|
||||
#chat-form_js:not([data-captcha]) > #chat-form_js__captcha-answer {
|
||||
display: none;
|
||||
}
|
||||
#chat-form_js:not([data-notice]) > #chat-form_js__notice {
|
||||
display: none;
|
||||
}
|
||||
#chat-form_js__notice {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
background: linear-gradient(#23232700, #2323277f 8%, #232327);
|
||||
height: 100%;
|
||||
display: grid;
|
||||
z-index: 1;
|
||||
}
|
||||
#chat-form_js__notice__button {
|
||||
color: inherit;
|
||||
border-color: black;
|
||||
background-color: #232327;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
display: grid;
|
||||
grid-gap: 0.375rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
box-shadow: 0 0 12px black;
|
||||
cursor: pointer;
|
||||
}
|
||||
#chat-form_js__notice__button__header {
|
||||
font-size: 14pt;
|
||||
line-height: 1.5;
|
||||
}
|
||||
#chat-form_nojs {
|
||||
height: 13ch;
|
||||
}
|
||||
#appearance-form_js {
|
||||
position: absolute;
|
||||
bottom: 3rem;
|
||||
padding: 0.5rem;
|
||||
margin: 0 1rem;
|
||||
width: calc(100% - 2rem);
|
||||
box-sizing: border-box;
|
||||
background: #343437df;
|
||||
border: 2px outset #434347;
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr min-content;
|
||||
grid-template-rows: 1fr 1fr auto;
|
||||
grid-gap: 0.375rem;
|
||||
}
|
||||
#appearance-form_js[data-hidden] {
|
||||
display: none;
|
||||
}
|
||||
#appearance-form_js__label-name,
|
||||
#appearance-form_js__label-tripcode {
|
||||
align-self: center;
|
||||
}
|
||||
#appearance-form_js__name,
|
||||
#appearance-form_js__password {
|
||||
min-width: 12ch;
|
||||
}
|
||||
#appearance-form_js__row {
|
||||
grid-column: 1 / span 3;
|
||||
grid-row: 3;
|
||||
display: grid;
|
||||
grid-template-columns: auto 4rem;
|
||||
align-items: end;
|
||||
}
|
||||
#appearance-form_js__row__result {
|
||||
font-weight: bold;
|
||||
font-size: 11pt;
|
||||
}
|
||||
#appearance-form_js__row__result > ul {
|
||||
margin: 0;
|
||||
padding-left: 1.125rem;
|
||||
font-size: 10pt;
|
||||
}
|
||||
#appearance-form_js__row__submit {
|
||||
min-height: 1.75rem;
|
||||
}
|
||||
#chat-live {
|
||||
position: relative;
|
||||
font-size: 9pt;
|
||||
|
@ -218,13 +421,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;
|
||||
|
@ -251,9 +454,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;
|
||||
}
|
||||
|
@ -276,7 +479,7 @@ footer {
|
|||
"info chat"
|
||||
"footer chat";
|
||||
}
|
||||
#toggle {
|
||||
#nav {
|
||||
display: none;
|
||||
}
|
||||
#info {
|
||||
|
|
|
@ -1,9 +1,25 @@
|
|||
import itertools
|
||||
import operator
|
||||
import time
|
||||
|
||||
from anonstream.segments import get_playlist, Offline
|
||||
import aiofiles
|
||||
from quart import current_app
|
||||
|
||||
def get_stream_title():
|
||||
return 'Stream title'
|
||||
from anonstream.segments import get_playlist, Offline
|
||||
from anonstream.wrappers import ttl_cache_async, with_timestamp
|
||||
from anonstream.user import get_watching_users
|
||||
|
||||
CONFIG = current_app.config
|
||||
USERS = current_app.users
|
||||
|
||||
@ttl_cache_async(CONFIG['STREAM_TITLE_CACHE_LIFETIME'])
|
||||
async def get_stream_title():
|
||||
try:
|
||||
async with aiofiles.open(CONFIG['STREAM_TITLE']) as fp:
|
||||
title = await fp.read(8192)
|
||||
except FileNotFoundError:
|
||||
title = ''
|
||||
return title
|
||||
|
||||
def get_stream_uptime(rounded=True):
|
||||
try:
|
||||
|
@ -20,6 +36,28 @@ def get_stream_uptime(rounded=True):
|
|||
uptime = round(uptime, 2) if rounded else uptime
|
||||
return uptime
|
||||
|
||||
@with_timestamp
|
||||
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_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:
|
||||
get_playlist()
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import asyncio
|
||||
import itertools
|
||||
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_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
|
||||
|
@ -27,12 +29,12 @@ def with_period(period):
|
|||
def periodically(f):
|
||||
@wraps(f)
|
||||
async def wrapper(*args, **kwargs):
|
||||
while True:
|
||||
for iteration in itertools.count():
|
||||
await f(iteration, *args, **kwargs)
|
||||
try:
|
||||
await sleep_and_collect_task(period)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
f(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
@ -40,22 +42,20 @@ def with_period(period):
|
|||
|
||||
@with_period(CONFIG['TASK_PERIOD_ROTATE_USERS'])
|
||||
@with_timestamp
|
||||
def t_sunset_users(timestamp):
|
||||
tokens = []
|
||||
for token in USERS_BY_TOKEN:
|
||||
user = USERS_BY_TOKEN[token]
|
||||
if not is_visible(timestamp, MESSAGES, user):
|
||||
tokens.append(token)
|
||||
async def t_sunset_users(timestamp, iteration):
|
||||
if iteration == 0:
|
||||
return
|
||||
|
||||
# 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(
|
||||
|
@ -67,7 +67,10 @@ def t_sunset_users(timestamp):
|
|||
)
|
||||
|
||||
@with_period(CONFIG['TASK_PERIOD_ROTATE_CAPTCHAS'])
|
||||
def t_expire_captchas():
|
||||
async def t_expire_captchas(iteration):
|
||||
if iteration == 0:
|
||||
return
|
||||
|
||||
to_delete = []
|
||||
for digest in CAPTCHAS:
|
||||
valid = CAPTCHA_SIGNER.validate(
|
||||
|
@ -76,13 +79,70 @@ def t_expire_captchas():
|
|||
)
|
||||
if not valid:
|
||||
to_delete.append(digest)
|
||||
|
||||
for digest in to_delete:
|
||||
CAPTCHAS.pop(digest)
|
||||
|
||||
@with_period(CONFIG['TASK_PERIOD_BROADCAST_USERS_UPDATE'])
|
||||
def t_broadcast_users_update():
|
||||
broadcast_users_update()
|
||||
async def t_broadcast_users_update(iteration):
|
||||
if iteration == 0:
|
||||
return
|
||||
else:
|
||||
broadcast_users_update()
|
||||
|
||||
@with_period(CONFIG['TASK_PERIOD_BROADCAST_STREAM_INFO_UPDATE'])
|
||||
async def t_broadcast_stream_info_update(iteration):
|
||||
if iteration == 0:
|
||||
title = await get_stream_title()
|
||||
uptime, viewership = get_stream_uptime_and_viewership()
|
||||
current_app.stream_title = title
|
||||
current_app.stream_uptime = uptime
|
||||
current_app.stream_viewership = viewership
|
||||
else:
|
||||
payload = {}
|
||||
|
||||
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 unexpectedly
|
||||
if current_app.stream_uptime is None:
|
||||
expected_uptime = None
|
||||
else:
|
||||
expected_uptime = (
|
||||
current_app.stream_uptime
|
||||
+ CONFIG['TASK_PERIOD_BROADCAST_STREAM_INFO_UPDATE']
|
||||
)
|
||||
current_app.stream_uptime = uptime
|
||||
if uptime is None and expected_uptime is None:
|
||||
stats_changed = False
|
||||
elif uptime is None or expected_uptime is None:
|
||||
stats_changed = True
|
||||
else:
|
||||
stats_changed = abs(uptime - expected_uptime) >= 0.0625
|
||||
|
||||
# Check if viewership has changed
|
||||
if current_app.stream_viewership != viewership:
|
||||
current_app.stream_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})
|
||||
|
||||
current_app.add_background_task(t_sunset_users)
|
||||
current_app.add_background_task(t_expire_captchas)
|
||||
current_app.add_background_task(t_broadcast_users_update)
|
||||
current_app.add_background_task(t_broadcast_stream_info_update)
|
||||
|
|
|
@ -2,28 +2,41 @@
|
|||
<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_messages', 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_chat_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>
|
||||
<noscript><iframe id="chat-form_nojs" src="{{ url_for('nojs_chat_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>
|
||||
</nav>
|
||||
<footer>anonstream 1.0.0 — <a href="#" target="_blank">source</a></footer>
|
||||
<footer>anonstream pre-1.0.0 — <a href="https://git.076.ne.jp/ninya9k/anonstream" target="_blank">source</a></footer>
|
||||
<script src="{{ url_for('static', filename='anonstream.js') }}" type="text/javascript"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
{%
|
||||
macro appearance(
|
||||
user,
|
||||
insignia_class,
|
||||
name_class,
|
||||
tag_class,
|
||||
tripcode_nbsp_class='for-tripcode',
|
||||
tripcode_class='tripcode for-tripcode'
|
||||
)
|
||||
%}
|
||||
{{- '' -}}
|
||||
{%- if user.broadcaster -%}
|
||||
<b class="{{ insignia_class }}" title="Broadcaster">##</b>
|
||||
{{- ' ' | safe -}}
|
||||
{%- endif -%}
|
||||
<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 }}"><b>{{ user.tag }}</b></sup>
|
||||
{%- endif -%}
|
||||
</span>
|
||||
{%- if user.tripcode -%}
|
||||
<span class="{{ tripcode_nbsp_class }}"> </span>
|
||||
{{- '' -}}
|
||||
<span class="{{ tripcode_class }}" style="background-color:{{ user.tripcode.background_color }};color:{{ user.tripcode.foreground_color }};">{{ user.tripcode.digest }}</span>
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
|
@ -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;
|
||||
|
@ -55,13 +56,14 @@
|
|||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
#notice h1 {
|
||||
#notice h2 {
|
||||
margin: 0;
|
||||
font-size: 18pt;
|
||||
line-height: 1.25;
|
||||
}
|
||||
#notice.verbose h1 {
|
||||
#notice.verbose h2 {
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
|
@ -72,7 +74,7 @@
|
|||
grid-gap: 0.375rem;
|
||||
}
|
||||
#chat-form__exit,
|
||||
#appearance-form__exit,
|
||||
#appearance-form__buttons__exit,
|
||||
#appearance-form__label-name,
|
||||
#appearance-form__label-password {
|
||||
font-size: 11pt;
|
||||
|
@ -119,6 +121,7 @@
|
|||
}
|
||||
|
||||
#appearance-form {
|
||||
display: grid;
|
||||
grid-auto-rows: 1fr 1fr 2rem;
|
||||
grid-auto-columns: min-content 1fr min-content;
|
||||
}
|
||||
|
@ -171,49 +174,51 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
#appearance-form {
|
||||
#toggle {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
#chat-form__exit > label,
|
||||
#appearance-form__buttons__exit > label {
|
||||
padding: 1px;
|
||||
}
|
||||
#toggle:focus-visible ~ #chat-form > #chat-form__exit > label,
|
||||
#toggle:focus-visible ~ #appearance-form #appearance-form__buttons__exit > label {
|
||||
padding: 0;
|
||||
border: 1px dotted;
|
||||
}
|
||||
#notice-radio {
|
||||
display: none;
|
||||
}
|
||||
#appearance:target ~ #appearance-form {
|
||||
display: grid;
|
||||
}
|
||||
#appearance:target ~ #chat-form {
|
||||
#toggle:checked ~ #chat-form,
|
||||
#toggle:not(:checked) ~ #appearance-form {
|
||||
display: none;
|
||||
}
|
||||
#chat:target ~ #appearance-form {
|
||||
#notice-radio:checked + #notice,
|
||||
#notice-radio:not(:checked) ~ #chat-form,
|
||||
#notice-radio:not(:checked) ~ #appearance-form {
|
||||
display: none;
|
||||
}
|
||||
{% if state.notice %}
|
||||
#chat-form {
|
||||
display: none;
|
||||
}
|
||||
#chat:target ~ #chat-form {
|
||||
display: grid;
|
||||
}
|
||||
#chat:target ~ #notice,
|
||||
#appearance:target ~ #notice {
|
||||
display: none;
|
||||
}
|
||||
{% endif %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chat"></div>
|
||||
<div id="appearance"></div>
|
||||
<input id="toggle" type="checkbox"{% if not prefer_chat_form %} checked{% endif %}>
|
||||
{% if state.notice %}
|
||||
<a id="notice" {% if state.verbose %}class="verbose" {% endif %}{% if prefer_chat_form %}href="#chat"{% else %}href="#appearance"{% endif %}>
|
||||
<header><h1>{{ state.notice }}</h1></header>
|
||||
<input id="notice-radio" type="radio">
|
||||
<label id="notice" for="notice-radio"{% if state.verbose %} class="verbose"{% endif %}>
|
||||
<header><h2>{{ state.notice }}</h2></header>
|
||||
<small>Click to dismiss</small>
|
||||
</a>
|
||||
</label>
|
||||
{% endif %}
|
||||
<form id="chat-form" action="{{ url_for('nojs_submit_message', token=user.token) }}" method="post">
|
||||
<input type="hidden" name="nonce" value="{{ nonce }}">
|
||||
<textarea id="chat-form__comment" name="comment" maxlength="512" {% if digest is none %}required {% endif %} placeholder="Send a message..." rows="1" tabindex="1">{{ state.comment }}</textarea>
|
||||
<textarea id="chat-form__comment" name="comment" maxlength="512" {% if digest is none %}required {% endif %} placeholder="Send a message..." rows="1" tabindex="1" autofocus>{{ state.comment }}</textarea>
|
||||
<input id="chat-form__submit" type="submit" value="Chat" tabindex="4" accesskey="p">
|
||||
<div id="chat-form__exit"><a href="#appearance">Settings</a></div>
|
||||
<div id="chat-form__exit"><label for="toggle" class="pseudolink">Settings</label></div>
|
||||
{% if digest %}
|
||||
<input type="hidden" name="captcha-digest" value="{{ digest }}">
|
||||
<input id="chat-form__captcha-image" type="image" formaction="{{ url_for('nojs_form_redirect', token=user.token) }}" formnovalidate src="{{ url_for('captcha', token=user.token, digest=digest) }}" width="72" height="30" alt="Captcha failed to load" title="Click for a new captcha" tabindex="2">
|
||||
<input id="chat-form__captcha-image" type="image" formaction="{{ url_for('nojs_chat_form_redirect', token=user.token) }}" formnovalidate src="{{ url_for('captcha', token=user.token, digest=digest) }}" width="72" height="30" alt="Captcha failed to load" title="Click for a new captcha" tabindex="2">
|
||||
<input id="chat-form__captcha-answer" name="captcha-answer" required placeholder="Captcha" tabindex="3">
|
||||
{% endif %}
|
||||
</form>
|
||||
|
@ -238,7 +243,7 @@
|
|||
<input id="appearance-form__password" name="password" type="password" placeholder="(tripcode password)" maxlength="1024">
|
||||
<div id="hide-password"><label for="password-toggle" class="pseudolink x">✗</label></div>
|
||||
<div id="appearance-form__buttons">
|
||||
<div id="appearance-form__exit"><a href="#chat">Return to chat</a></div>
|
||||
<div id="appearance-form__buttons__exit"><label for="toggle" class="pseudolink">Return to chat</label></div>
|
||||
<input type="submit" value="Update">
|
||||
</div>
|
||||
</form>
|
|
@ -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_messages_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 {
|
||||
|
@ -97,6 +100,8 @@
|
|||
.chat-message__time {
|
||||
color: #b2b2b3;
|
||||
font-size: 10pt;
|
||||
}
|
||||
.chat-message__insignia {
|
||||
cursor: help;
|
||||
}
|
||||
.chat-message__name {
|
||||
|
@ -126,8 +131,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_messages_redirect', token=user.token) }}">
|
||||
<header>Timed out</header>
|
||||
<small>Click to refresh</small>
|
||||
</a>
|
||||
|
@ -136,16 +141,20 @@
|
|||
{% for message in messages | reverse %}
|
||||
<li class="chat-message" data-seq="{{ message.seq }}" data-token-hash="{{ user.token_hash }}">
|
||||
{% 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> <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"> </span><span class="tripcode for-tripcode" style="background-color:{{ user.tripcode.background_color }};color:{{ user.tripcode.foreground_color }};">{{ user.tripcode.digest }}</span>{% endif %}: <span class="chat-message__markup">{{ message.markup }}</span>
|
||||
<time class="chat-message__time" datetime="{{ message.date }}T{{ message.time_seconds }}Z" title="{{ message.date }} {{ message.time_seconds }}">{{ message.time_minutes }}</time>
|
||||
{{- ' ' | safe -}}
|
||||
{{ appearance(user, insignia_class='chat-message__insignia', name_class='chat-message__name', tag_class='chat-message__name__tag') }}
|
||||
{{- ': ' -}}
|
||||
<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_messages_redirect', token=user.token) }}">Click to refresh</a>
|
||||
</aside>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,114 @@
|
|||
{% 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__insignia {
|
||||
cursor: help;
|
||||
}
|
||||
.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, insignia_class='user__insignia', 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, insignia_class='user__insignia', name_class='user__name', tag_class='user__name__tag') -}}
|
||||
{%- if user.token == user_listed.token %} (You){% endif -%}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
|
@ -2,20 +2,174 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="6">
|
||||
<style>
|
||||
body {
|
||||
overflow-y: auto;
|
||||
margin: 1ch 1.5ch;
|
||||
margin: 0.75ch 1.25ch;
|
||||
font-family: sans-serif;
|
||||
color: #ddd;
|
||||
}
|
||||
#title {
|
||||
#float {
|
||||
float: right;
|
||||
font-size: 11pt;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-gap: 2.5ch;
|
||||
}
|
||||
#float__form {
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
#float__uptime {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
#uptime-static[data-hidden], #uptime-static__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
{% if uptime is not none and uptime < 360000 %}
|
||||
#s0::before, #s1::before,
|
||||
#m0::before, #m1::before,
|
||||
#h0::before, #h1::before {
|
||||
animation-timing-function: step-end;
|
||||
animation-delay: {{ -uptime }}s;
|
||||
animation-iteration-count: infinite;
|
||||
content: "";
|
||||
}
|
||||
#m0::after, #h0::after {
|
||||
content: ":";
|
||||
}
|
||||
#s0::before {
|
||||
animation-name: tick10;
|
||||
animation-duration: 10s;
|
||||
}
|
||||
#s1::before {
|
||||
animation-name: tick6;
|
||||
animation-duration: 60s;
|
||||
}
|
||||
#m0::before {
|
||||
animation-name: tick10;
|
||||
animation-duration: 600s;
|
||||
}
|
||||
#m1::before {
|
||||
animation-name: tick6;
|
||||
animation-duration: 3600s;
|
||||
}
|
||||
#h0::before {
|
||||
animation-name: tick10;
|
||||
animation-duration: 36000s;
|
||||
}
|
||||
#h1::before {
|
||||
animation-name: tick10;
|
||||
animation-duration: 360000s;
|
||||
}
|
||||
#m1, #h0, #h1 {
|
||||
display: inline-block;
|
||||
animation: appear step-end both;
|
||||
}
|
||||
#m1 {
|
||||
animation-duration: {{ 600 - uptime }}s;
|
||||
}
|
||||
#h0 {
|
||||
animation-duration: {{ 3600 - uptime }}s;
|
||||
}
|
||||
#h1 {
|
||||
animation-duration: {{ 36000 - uptime }}s;
|
||||
}
|
||||
#uptime-dynamic-overflow {
|
||||
animation: appear step-end {{ 360000 - uptime }}s backwards;
|
||||
}
|
||||
#uptime-dynamic {
|
||||
animation: disappear step-end {{ 360000 - uptime }}s forwards;
|
||||
}
|
||||
@keyframes appear {
|
||||
from {
|
||||
width: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
@keyframes disappear {
|
||||
to {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes tick6 {
|
||||
00.0000% { content: "0"; }
|
||||
16.6667% { content: "1"; }
|
||||
33.3333% { content: "2"; }
|
||||
50.0000% { content: "3"; }
|
||||
66.6667% { content: "4"; }
|
||||
83.3333% { content: "5"; }
|
||||
}
|
||||
@keyframes tick10 {
|
||||
00% { content: "0"; }
|
||||
10% { content: "1"; }
|
||||
20% { content: "2"; }
|
||||
30% { content: "3"; }
|
||||
40% { content: "4"; }
|
||||
50% { content: "5"; }
|
||||
60% { content: "6"; }
|
||||
70% { content: "7"; }
|
||||
80% { content: "8"; }
|
||||
90% { content: "9"; }
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
#title > h1 {
|
||||
margin: 0;
|
||||
font-size: 18pt;
|
||||
line-height: 1.125;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header id="title">{{ title }}</header>
|
||||
{% 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">
|
||||
<div id="uptime-static"{% if uptime < 360000 %} data-hidden=""{% endif %}>
|
||||
<span id="uptime-static__label">Uptime:</span>
|
||||
<span>
|
||||
{%- if uptime >= 3600 -%}
|
||||
{{- (uptime // 3600) | int -}}
|
||||
{{- ':' -}}
|
||||
{{- '%02.0f' | format(uptime % 3600 // 60) -}}
|
||||
{%- else -%}
|
||||
{{- uptime % 3600 // 60 | int -}}
|
||||
{%- endif -%}
|
||||
{{- ':' -}}
|
||||
{{- '%02.0f' | format(uptime % 60) -}}
|
||||
</span>
|
||||
</div>
|
||||
{% if uptime < 360000 %}
|
||||
<div id="uptime-dynamic">
|
||||
<span id="h1"></span>
|
||||
{{- '' -}}
|
||||
<span id="h0"></span>
|
||||
{{- '' -}}
|
||||
<span id="m1"></span>
|
||||
{{- '' -}}
|
||||
<span id="m0"></span>
|
||||
{{- '' -}}
|
||||
<span id="s1"></span>
|
||||
{{- '' -}}
|
||||
<span id="s0"></span>
|
||||
</div>
|
||||
<div id="uptime-dynamic-overflow">100+ hours</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
{% endif %}
|
||||
<header id="title"><h1>{{ title }}</h1></header>
|
||||
</body>
|
||||
</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>
|
|
@ -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_default_name, 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
|
||||
|
@ -64,6 +69,8 @@ def try_change_appearance(user, name, color, password,
|
|||
|
||||
def change_name(user, name, dry_run=False):
|
||||
if dry_run:
|
||||
if name == get_default_name(user):
|
||||
name = None
|
||||
if name is not None:
|
||||
if len(name) == 0:
|
||||
raise BadAppearance('Name was empty')
|
||||
|
@ -86,7 +93,7 @@ def change_color(user, color, dry_run=False):
|
|||
if contrast < min_contrast:
|
||||
raise BadAppearance(
|
||||
'Colour had insufficient contrast:',
|
||||
(f'{contrast:.2f}', f'/{min_contrast}'),
|
||||
(f'{contrast:.2f}', f'/{min_contrast:.2f}'),
|
||||
)
|
||||
else:
|
||||
user['color'] = color
|
||||
|
@ -112,13 +119,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 +156,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']),
|
||||
}
|
||||
|
|
|
@ -1,19 +1,49 @@
|
|||
from enum import Enum
|
||||
|
||||
WS = Enum('WS', names=('MESSAGE, CAPTCHA, APPEARANCE'))
|
||||
|
||||
class Malformed(Exception):
|
||||
pass
|
||||
|
||||
def get(t, pairs, key, default=None):
|
||||
value = pairs.get(key, default)
|
||||
if isinstance(value, t):
|
||||
return value
|
||||
else:
|
||||
raise Malformed(f'malformed {key}')
|
||||
|
||||
def parse_websocket_data(receipt):
|
||||
if not isinstance(receipt, dict):
|
||||
raise Malformed('not a json object')
|
||||
|
||||
comment = receipt.get('comment')
|
||||
if not isinstance(comment, str):
|
||||
raise Malformed('malformed comment')
|
||||
match receipt.get('type'):
|
||||
case 'message':
|
||||
form = get(dict, receipt, 'form')
|
||||
nonce = get(str, form, 'nonce')
|
||||
comment = get(str, form, 'comment')
|
||||
digest = get(str, form, 'captcha-digest', '')
|
||||
answer = get(str, form, 'captcha-answer', '')
|
||||
return WS.MESSAGE, (nonce, comment, digest, answer)
|
||||
|
||||
nonce = receipt.get('nonce')
|
||||
if not isinstance(nonce, str):
|
||||
raise Malformed('malformed nonce')
|
||||
case 'appearance':
|
||||
form = get(dict, receipt, 'form')
|
||||
name = get(str, form, 'name').strip()
|
||||
if len(name) == 0:
|
||||
name = None
|
||||
color = get(str, form, 'color')
|
||||
password = get(str, form, 'password')
|
||||
#match get(str | None, form, 'want-tripcode'):
|
||||
# case '0':
|
||||
# want_tripcode = False
|
||||
# case '1':
|
||||
# want_tripcode = True
|
||||
# case _:
|
||||
# want_tripcode = None
|
||||
want_tripcode = bool(password)
|
||||
return WS.APPEARANCE, (name, color, password, want_tripcode)
|
||||
|
||||
digest = receipt.get('captcha-digest', '')
|
||||
answer = receipt.get('captcha-answer', '')
|
||||
case 'captcha':
|
||||
return WS.CAPTCHA, ()
|
||||
|
||||
return nonce, comment, digest, answer
|
||||
case _:
|
||||
raise Malformed('malformed type')
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import asyncio
|
||||
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_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
|
||||
from anonstream.user import get_all_users_for_websocket, see, verify, deverify, BadCaptcha, try_change_appearance
|
||||
from anonstream.utils.chat import generate_nonce
|
||||
from anonstream.utils.websocket import parse_websocket_data, Malformed
|
||||
from anonstream.utils.websocket import parse_websocket_data, Malformed, WS
|
||||
|
||||
CONFIG = current_app.config
|
||||
|
||||
|
@ -15,8 +16,8 @@ async def websocket_outbound(queue, user):
|
|||
payload = {
|
||||
'type': 'init',
|
||||
'nonce': generate_nonce(),
|
||||
'title': get_stream_title(),
|
||||
'uptime': get_stream_uptime(),
|
||||
'title': await get_stream_title(),
|
||||
'stats': get_stream_uptime_and_viewership(for_websocket=True),
|
||||
'messages': get_all_messages_for_websocket(),
|
||||
'users': get_all_users_for_websocket(),
|
||||
'default': {
|
||||
|
@ -33,10 +34,14 @@ async def websocket_outbound(queue, user):
|
|||
|
||||
async def websocket_inbound(queue, user):
|
||||
while True:
|
||||
receipt = await websocket.receive_json()
|
||||
see(user)
|
||||
try:
|
||||
nonce, comment, digest, answer = parse_websocket_data(receipt)
|
||||
receipt = await websocket.receive_json()
|
||||
except json.JSONDecodeError:
|
||||
receipt = None
|
||||
finally:
|
||||
see(user)
|
||||
try:
|
||||
receipt_type, parsed = parse_websocket_data(receipt)
|
||||
except Malformed as e:
|
||||
error , *_ = e.args
|
||||
payload = {
|
||||
|
@ -44,29 +49,65 @@ async def websocket_inbound(queue, user):
|
|||
'because': error,
|
||||
}
|
||||
else:
|
||||
try:
|
||||
verification_happened = verify(user, digest, answer)
|
||||
except BadCaptcha as e:
|
||||
notice, *_ = e.args
|
||||
else:
|
||||
try:
|
||||
message_was_added = add_chat_message(
|
||||
user,
|
||||
nonce,
|
||||
comment,
|
||||
ignore_empty=verification_happened,
|
||||
)
|
||||
except Rejected as e:
|
||||
notice, *_ = e.args
|
||||
else:
|
||||
deverify(user)
|
||||
notice = None
|
||||
payload = {
|
||||
'type': 'ack',
|
||||
'nonce': nonce,
|
||||
'next': generate_nonce(),
|
||||
'notice': notice,
|
||||
'clear': message_was_added,
|
||||
'digest': get_random_captcha_digest_for(user),
|
||||
}
|
||||
match receipt_type:
|
||||
case WS.MESSAGE:
|
||||
handle = handle_inbound_message
|
||||
case WS.APPEARANCE:
|
||||
handle = handle_inbound_appearance
|
||||
case WS.CAPTCHA:
|
||||
handle = handle_inbound_captcha
|
||||
payload = handle(user, *parsed)
|
||||
|
||||
queue.put_nowait(payload)
|
||||
|
||||
def handle_inbound_captcha(user):
|
||||
return {
|
||||
'type': 'captcha',
|
||||
'digest': get_random_captcha_digest_for(user),
|
||||
}
|
||||
|
||||
def handle_inbound_appearance(user, name, color, password, want_tripcode):
|
||||
errors = try_change_appearance(user, name, color, password, want_tripcode)
|
||||
if errors:
|
||||
return {
|
||||
'type': 'appearance',
|
||||
'errors': [error.args for error in errors],
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'type': 'appearance',
|
||||
'result': 'Changed appearance',
|
||||
'name': user['name'],
|
||||
'color': user['color'],
|
||||
#'tripcode': user['tripcode'],
|
||||
}
|
||||
|
||||
def handle_inbound_message(user, nonce, comment, digest, answer):
|
||||
try:
|
||||
verification_happened = verify(user, digest, answer)
|
||||
except BadCaptcha as e:
|
||||
notice, *_ = e.args
|
||||
message_was_added = False
|
||||
else:
|
||||
try:
|
||||
message_was_added = add_chat_message(
|
||||
user,
|
||||
nonce,
|
||||
comment,
|
||||
ignore_empty=verification_happened,
|
||||
)
|
||||
except Rejected as e:
|
||||
notice, *_ = e.args
|
||||
message_was_added = False
|
||||
else:
|
||||
notice = None
|
||||
if message_was_added:
|
||||
deverify(user)
|
||||
return {
|
||||
'type': 'ack',
|
||||
'nonce': nonce,
|
||||
'next': generate_nonce(),
|
||||
'notice': notice,
|
||||
'clear': message_was_added,
|
||||
'digest': get_random_captcha_digest_for(user),
|
||||
}
|
||||
|
|
|
@ -53,3 +53,24 @@ def ttl_cache(ttl):
|
|||
return wrapper
|
||||
|
||||
return ttl_cache_specific
|
||||
|
||||
def ttl_cache_async(ttl):
|
||||
'''
|
||||
Async version of `ttl_cache`. Wraps zero-argument coroutines.
|
||||
'''
|
||||
def ttl_cache_specific(f):
|
||||
value, expires = None, None
|
||||
|
||||
@wraps(f)
|
||||
async def wrapper():
|
||||
nonlocal value, expires
|
||||
|
||||
if expires is None or time.monotonic() >= expires:
|
||||
value = await f()
|
||||
expires = time.monotonic() + ttl
|
||||
|
||||
return value
|
||||
|
||||
return wrapper
|
||||
|
||||
return ttl_cache_specific
|
||||
|
|
|
@ -12,6 +12,10 @@ search_cooldown = 0.25
|
|||
search_timeout = 5.0
|
||||
stream_initial_buffer = 3
|
||||
|
||||
[title]
|
||||
file = "title.txt"
|
||||
file_cache_lifetime = 0.5
|
||||
|
||||
[captcha]
|
||||
lifetime = 1800
|
||||
fonts = []
|
||||
|
@ -30,6 +34,7 @@ chat_scrollback = 256
|
|||
rotate_users = 60.0
|
||||
rotate_captchas = 60.0
|
||||
broadcast_users_update = 4.0
|
||||
broadcast_stream_info_update = 3.0
|
||||
|
||||
[names]
|
||||
broadcaster = "Broadcaster"
|
||||
|
@ -40,6 +45,7 @@ max_comment_length = 512
|
|||
max_name_length = 24
|
||||
min_name_contrast = 3.0
|
||||
background_color = "#232327"
|
||||
legacy_tripcode_algorithm = false
|
||||
|
||||
[flood]
|
||||
duration = 20.0
|
||||
|
|
読み込み中…
新しいイシューから参照