Realtime UI updates via WebSocket (#3183)

pull/3221/head
dgtlmoon 2025-05-26 20:12:32 +02:00 zatwierdzone przez GitHub
rodzic a58fc82575
commit 0f65178190
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
34 zmienionych plików z 1304 dodań i 252 usunięć

Wyświetl plik

@ -5,6 +5,7 @@ recursive-include changedetectionio/conditions *
recursive-include changedetectionio/model * recursive-include changedetectionio/model *
recursive-include changedetectionio/notification * recursive-include changedetectionio/notification *
recursive-include changedetectionio/processors * recursive-include changedetectionio/processors *
recursive-include changedetectionio/realtime *
recursive-include changedetectionio/static * recursive-include changedetectionio/static *
recursive-include changedetectionio/templates * recursive-include changedetectionio/templates *
recursive-include changedetectionio/tests * recursive-include changedetectionio/tests *

Wyświetl plik

@ -7,14 +7,12 @@ __version__ = '0.49.17'
from changedetectionio.strtobool import strtobool from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
import os import os
os.environ['EVENTLET_NO_GREENDNS'] = 'yes'
import eventlet
import eventlet.wsgi
import getopt import getopt
import platform import platform
import signal import signal
import socket import socket
import sys import sys
from werkzeug.serving import run_simple
from changedetectionio import store from changedetectionio import store
from changedetectionio.flask_app import changedetection_app from changedetectionio.flask_app import changedetection_app
@ -33,8 +31,17 @@ def sigshutdown_handler(_signo, _stack_frame):
logger.critical(f'Shutdown: Got Signal - {name} ({_signo}), Saving DB to disk and calling shutdown') logger.critical(f'Shutdown: Got Signal - {name} ({_signo}), Saving DB to disk and calling shutdown')
datastore.sync_to_json() datastore.sync_to_json()
logger.success('Sync JSON to disk complete.') logger.success('Sync JSON to disk complete.')
# This will throw a SystemExit exception, because eventlet.wsgi.server doesn't know how to deal with it.
# Solution: move to gevent or other server in the future (#2014) # Shutdown socketio server if available
from changedetectionio.flask_app import socketio_server
if socketio_server and hasattr(socketio_server, 'shutdown'):
try:
logger.info("Shutting down Socket.IO server...")
socketio_server.shutdown()
except Exception as e:
logger.error(f"Error shutting down Socket.IO server: {str(e)}")
# Set flags for clean shutdown
datastore.stop_thread = True datastore.stop_thread = True
app.config.exit.set() app.config.exit.set()
sys.exit() sys.exit()
@ -196,13 +203,85 @@ def main():
s_type = socket.AF_INET6 if ipv6_enabled else socket.AF_INET s_type = socket.AF_INET6 if ipv6_enabled else socket.AF_INET
if ssl_mode: # Get socketio_server from flask_app
# @todo finalise SSL config, but this should get you in the right direction if you need it. from changedetectionio.flask_app import socketio_server
eventlet.wsgi.server(eventlet.wrap_ssl(eventlet.listen((host, port), s_type),
certfile='cert.pem',
keyfile='privkey.pem',
server_side=True), app)
if socketio_server and datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab'):
logger.info("Starting server with Socket.IO support (using threading)...")
# Use Flask-SocketIO's run method with error handling for Werkzeug warning
# This is the cleanest approach that works with all Flask-SocketIO versions
# Use '0.0.0.0' as the default host if none is specified
# This will listen on all available interfaces
listen_host = '0.0.0.0' if host == '' else host
logger.info(f"Using host: {listen_host} and port: {port}")
try:
# First try with the allow_unsafe_werkzeug parameter (newer versions)
if ssl_mode:
socketio_server.run(
app,
host=listen_host,
port=int(port),
certfile='cert.pem',
keyfile='privkey.pem',
debug=False,
use_reloader=False,
allow_unsafe_werkzeug=True # Only in newer versions
)
else:
socketio_server.run(
app,
host=listen_host,
port=int(port),
debug=False,
use_reloader=False,
allow_unsafe_werkzeug=True # Only in newer versions
)
except TypeError:
# If allow_unsafe_werkzeug is not a valid parameter, try without it
logger.info("Falling back to basic run method without allow_unsafe_werkzeug")
# Override the werkzeug safety check by setting an environment variable
os.environ['WERKZEUG_RUN_MAIN'] = 'true'
if ssl_mode:
socketio_server.run(
app,
host=listen_host,
port=int(port),
certfile='cert.pem',
keyfile='privkey.pem',
debug=False,
use_reloader=False
)
else:
socketio_server.run(
app,
host=listen_host,
port=int(port),
debug=False,
use_reloader=False
)
else: else:
eventlet.wsgi.server(eventlet.listen((host, int(port)), s_type), app) logger.warning("Socket.IO server not initialized, falling back to standard WSGI server")
# Fallback to standard WSGI server if socketio_server is not available
listen_host = '0.0.0.0' if host == '' else host
if ssl_mode:
# Use Werkzeug's run_simple with SSL support
run_simple(
hostname=listen_host,
port=int(port),
application=app,
use_reloader=False,
use_debugger=False,
ssl_context=('cert.pem', 'privkey.pem')
)
else:
# Use Werkzeug's run_simple for standard HTTP
run_simple(
hostname=listen_host,
port=int(port),
application=app,
use_reloader=False,
use_debugger=False
)

Wyświetl plik

@ -246,6 +246,10 @@ nav
{{ render_checkbox_field(form.application.form.ui.form.open_diff_in_new_tab, class="open_diff_in_new_tab") }} {{ render_checkbox_field(form.application.form.ui.form.open_diff_in_new_tab, class="open_diff_in_new_tab") }}
<span class="pure-form-message-inline">Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.</span> <span class="pure-form-message-inline">Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.</span>
</div> </div>
<div class="pure-control-group">
<span class="pure-form-message-inline">Enable realtime updates in the UI</span>
</div>
</div> </div>
<div class="tab-pane-inner" id="proxies"> <div class="tab-pane-inner" id="proxies">
<div id="recommended-proxy"> <div id="recommended-proxy">

Wyświetl plik

@ -3,12 +3,13 @@ from flask import Blueprint, request, redirect, url_for, flash, render_template,
from loguru import logger from loguru import logger
from functools import wraps from functools import wraps
from changedetectionio.blueprint.ui.ajax import constuct_ui_ajax_blueprint
from changedetectionio.store import ChangeDetectionStore from changedetectionio.store import ChangeDetectionStore
from changedetectionio.blueprint.ui.edit import construct_blueprint as construct_edit_blueprint from changedetectionio.blueprint.ui.edit import construct_blueprint as construct_edit_blueprint
from changedetectionio.blueprint.ui.notification import construct_blueprint as construct_notification_blueprint from changedetectionio.blueprint.ui.notification import construct_blueprint as construct_notification_blueprint
from changedetectionio.blueprint.ui.views import construct_blueprint as construct_views_blueprint from changedetectionio.blueprint.ui.views import construct_blueprint as construct_views_blueprint
def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_update_threads, queuedWatchMetaData): def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_update_threads, queuedWatchMetaData, watch_check_update):
ui_blueprint = Blueprint('ui', __name__, template_folder="templates") ui_blueprint = Blueprint('ui', __name__, template_folder="templates")
# Register the edit blueprint # Register the edit blueprint
@ -20,9 +21,12 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
ui_blueprint.register_blueprint(notification_blueprint) ui_blueprint.register_blueprint(notification_blueprint)
# Register the views blueprint # Register the views blueprint
views_blueprint = construct_views_blueprint(datastore, update_q, queuedWatchMetaData) views_blueprint = construct_views_blueprint(datastore, update_q, queuedWatchMetaData, watch_check_update)
ui_blueprint.register_blueprint(views_blueprint) ui_blueprint.register_blueprint(views_blueprint)
ui_ajax_blueprint = constuct_ui_ajax_blueprint(datastore, update_q, running_update_threads, queuedWatchMetaData, watch_check_update)
ui_blueprint.register_blueprint(ui_ajax_blueprint)
# Import the login decorator # Import the login decorator
from changedetectionio.auth_decorator import login_optionally_required from changedetectionio.auth_decorator import login_optionally_required
@ -35,7 +39,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
flash('Watch not found', 'error') flash('Watch not found', 'error')
else: else:
flash("Cleared snapshot history for watch {}".format(uuid)) flash("Cleared snapshot history for watch {}".format(uuid))
return redirect(url_for('watchlist.index')) return redirect(url_for('watchlist.index'))
@ui_blueprint.route("/clear_history", methods=['GET', 'POST']) @ui_blueprint.route("/clear_history", methods=['GET', 'POST'])
@ -47,7 +50,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
if confirmtext == 'clear': if confirmtext == 'clear':
for uuid in datastore.data['watching'].keys(): for uuid in datastore.data['watching'].keys():
datastore.clear_watch_history(uuid) datastore.clear_watch_history(uuid)
flash("Cleared snapshot history for all watches") flash("Cleared snapshot history for all watches")
else: else:
flash('Incorrect confirmation text.', 'error') flash('Incorrect confirmation text.', 'error')
@ -153,53 +155,46 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
@login_optionally_required @login_optionally_required
def form_watch_list_checkbox_operations(): def form_watch_list_checkbox_operations():
op = request.form['op'] op = request.form['op']
uuids = request.form.getlist('uuids') uuids = [u.strip() for u in request.form.getlist('uuids') if u]
if (op == 'delete'): if (op == 'delete'):
for uuid in uuids: for uuid in uuids:
uuid = uuid.strip()
if datastore.data['watching'].get(uuid): if datastore.data['watching'].get(uuid):
datastore.delete(uuid.strip()) datastore.delete(uuid)
flash("{} watches deleted".format(len(uuids))) flash("{} watches deleted".format(len(uuids)))
elif (op == 'pause'): elif (op == 'pause'):
for uuid in uuids: for uuid in uuids:
uuid = uuid.strip()
if datastore.data['watching'].get(uuid): if datastore.data['watching'].get(uuid):
datastore.data['watching'][uuid.strip()]['paused'] = True datastore.data['watching'][uuid]['paused'] = True
flash("{} watches paused".format(len(uuids))) flash("{} watches paused".format(len(uuids)))
elif (op == 'unpause'): elif (op == 'unpause'):
for uuid in uuids: for uuid in uuids:
uuid = uuid.strip()
if datastore.data['watching'].get(uuid): if datastore.data['watching'].get(uuid):
datastore.data['watching'][uuid.strip()]['paused'] = False datastore.data['watching'][uuid.strip()]['paused'] = False
flash("{} watches unpaused".format(len(uuids))) flash("{} watches unpaused".format(len(uuids)))
elif (op == 'mark-viewed'): elif (op == 'mark-viewed'):
for uuid in uuids: for uuid in uuids:
uuid = uuid.strip()
if datastore.data['watching'].get(uuid): if datastore.data['watching'].get(uuid):
datastore.set_last_viewed(uuid, int(time.time())) datastore.set_last_viewed(uuid, int(time.time()))
flash("{} watches updated".format(len(uuids))) flash("{} watches updated".format(len(uuids)))
elif (op == 'mute'): elif (op == 'mute'):
for uuid in uuids: for uuid in uuids:
uuid = uuid.strip()
if datastore.data['watching'].get(uuid): if datastore.data['watching'].get(uuid):
datastore.data['watching'][uuid.strip()]['notification_muted'] = True datastore.data['watching'][uuid]['notification_muted'] = True
flash("{} watches muted".format(len(uuids))) flash("{} watches muted".format(len(uuids)))
elif (op == 'unmute'): elif (op == 'unmute'):
for uuid in uuids: for uuid in uuids:
uuid = uuid.strip()
if datastore.data['watching'].get(uuid): if datastore.data['watching'].get(uuid):
datastore.data['watching'][uuid.strip()]['notification_muted'] = False datastore.data['watching'][uuid]['notification_muted'] = False
flash("{} watches un-muted".format(len(uuids))) flash("{} watches un-muted".format(len(uuids)))
elif (op == 'recheck'): elif (op == 'recheck'):
for uuid in uuids: for uuid in uuids:
uuid = uuid.strip()
if datastore.data['watching'].get(uuid): if datastore.data['watching'].get(uuid):
# Recheck and require a full reprocessing # Recheck and require a full reprocessing
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
@ -207,14 +202,12 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
elif (op == 'clear-errors'): elif (op == 'clear-errors'):
for uuid in uuids: for uuid in uuids:
uuid = uuid.strip()
if datastore.data['watching'].get(uuid): if datastore.data['watching'].get(uuid):
datastore.data['watching'][uuid]["last_error"] = False datastore.data['watching'][uuid]["last_error"] = False
flash(f"{len(uuids)} watches errors cleared") flash(f"{len(uuids)} watches errors cleared")
elif (op == 'clear-history'): elif (op == 'clear-history'):
for uuid in uuids: for uuid in uuids:
uuid = uuid.strip()
if datastore.data['watching'].get(uuid): if datastore.data['watching'].get(uuid):
datastore.clear_watch_history(uuid) datastore.clear_watch_history(uuid)
flash("{} watches cleared/reset.".format(len(uuids))) flash("{} watches cleared/reset.".format(len(uuids)))
@ -224,12 +217,11 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
default_notification_format_for_watch default_notification_format_for_watch
) )
for uuid in uuids: for uuid in uuids:
uuid = uuid.strip()
if datastore.data['watching'].get(uuid): if datastore.data['watching'].get(uuid):
datastore.data['watching'][uuid.strip()]['notification_title'] = None datastore.data['watching'][uuid]['notification_title'] = None
datastore.data['watching'][uuid.strip()]['notification_body'] = None datastore.data['watching'][uuid]['notification_body'] = None
datastore.data['watching'][uuid.strip()]['notification_urls'] = [] datastore.data['watching'][uuid]['notification_urls'] = []
datastore.data['watching'][uuid.strip()]['notification_format'] = default_notification_format_for_watch datastore.data['watching'][uuid]['notification_format'] = default_notification_format_for_watch
flash("{} watches set to use default notification settings".format(len(uuids))) flash("{} watches set to use default notification settings".format(len(uuids)))
elif (op == 'assign-tag'): elif (op == 'assign-tag'):
@ -238,7 +230,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
tag_uuid = datastore.add_tag(title=op_extradata) tag_uuid = datastore.add_tag(title=op_extradata)
if op_extradata and tag_uuid: if op_extradata and tag_uuid:
for uuid in uuids: for uuid in uuids:
uuid = uuid.strip()
if datastore.data['watching'].get(uuid): if datastore.data['watching'].get(uuid):
# Bug in old versions caused by bad edit page/tag handler # Bug in old versions caused by bad edit page/tag handler
if isinstance(datastore.data['watching'][uuid]['tags'], str): if isinstance(datastore.data['watching'][uuid]['tags'], str):
@ -248,6 +239,11 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
flash(f"{len(uuids)} watches were tagged") flash(f"{len(uuids)} watches were tagged")
if uuids:
for uuid in uuids:
# with app.app_context():
watch_check_update.send(watch_uuid=uuid)
return redirect(url_for('watchlist.index')) return redirect(url_for('watchlist.index'))

Wyświetl plik

@ -0,0 +1,35 @@
import time
from blinker import signal
from flask import Blueprint, request, redirect, url_for, flash, render_template, session
from changedetectionio.store import ChangeDetectionStore
def constuct_ui_ajax_blueprint(datastore: ChangeDetectionStore, update_q, running_update_threads, queuedWatchMetaData, watch_check_update):
ui_ajax_blueprint = Blueprint('ajax', __name__, template_folder="templates", url_prefix='/ajax')
# Import the login decorator
from changedetectionio.auth_decorator import login_optionally_required
@ui_ajax_blueprint.route("/toggle", methods=['POST'])
@login_optionally_required
def ajax_toggler():
op = request.values.get('op')
uuid = request.values.get('uuid')
if op and datastore.data['watching'].get(uuid):
if op == 'pause':
datastore.data['watching'][uuid].toggle_pause()
elif op == 'mute':
datastore.data['watching'][uuid].toggle_mute()
elif op == 'recheck':
update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
watch_check_update = signal('watch_check_update')
if watch_check_update:
watch_check_update.send(watch_uuid=uuid)
return 'OK'
return ui_ajax_blueprint

Wyświetl plik

@ -8,7 +8,7 @@ from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required from changedetectionio.auth_decorator import login_optionally_required
from changedetectionio import html_tools from changedetectionio import html_tools
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData): def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData, watch_check_update):
views_blueprint = Blueprint('ui_views', __name__, template_folder="../ui/templates") views_blueprint = Blueprint('ui_views', __name__, template_folder="../ui/templates")
@views_blueprint.route("/preview/<string:uuid>", methods=['GET']) @views_blueprint.route("/preview/<string:uuid>", methods=['GET'])

Wyświetl plik

@ -72,31 +72,33 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
per_page=datastore.data['settings']['application'].get('pager_size', 50), css_framework="semantic") per_page=datastore.data['settings']['application'].get('pager_size', 50), css_framework="semantic")
sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title']) sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title'])
output = render_template( output = render_template(
"watch-overview.html", "watch-overview.html",
active_tag=active_tag, active_tag=active_tag,
active_tag_uuid=active_tag_uuid, active_tag_uuid=active_tag_uuid,
app_rss_token=datastore.data['settings']['application'].get('rss_access_token'), app_rss_token=datastore.data['settings']['application'].get('rss_access_token'),
datastore=datastore, ajax_toggle_url=url_for('ui.ajax.ajax_toggler'),
errored_count=errored_count, datastore=datastore,
form=form, errored_count=errored_count,
guid=datastore.data['app_guid'], form=form,
has_proxies=datastore.proxy_list, guid=datastore.data['app_guid'],
has_unviewed=datastore.has_unviewed, has_proxies=datastore.proxy_list,
hosted_sticky=os.getenv("SALTED_PASS", False) == False, has_unviewed=datastore.has_unviewed,
now_time_server=time.time(), hosted_sticky=os.getenv("SALTED_PASS", False) == False,
pagination=pagination, now_time_server=round(time.time()),
queued_uuids=[q_uuid.item['uuid'] for q_uuid in update_q.queue], pagination=pagination,
search_q=request.args.get('q', '').strip(), queued_uuids=[q_uuid.item['uuid'] for q_uuid in update_q.queue],
sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'), search_q=request.args.get('q', '').strip(),
sort_order=request.args.get('order') if request.args.get('order') else request.cookies.get('order'), sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'),
system_default_fetcher=datastore.data['settings']['application'].get('fetch_backend'), sort_order=request.args.get('order') if request.args.get('order') else request.cookies.get('order'),
tags=sorted_tags, system_default_fetcher=datastore.data['settings']['application'].get('fetch_backend'),
watches=sorted_watches tags=sorted_tags,
) watches=sorted_watches
)
if session.get('share-link'): if session.get('share-link'):
del(session['share-link']) del (session['share-link'])
resp = make_response(output) resp = make_response(output)

Wyświetl plik

@ -4,6 +4,7 @@
<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> <script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script> <script src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script>
<script>let nowtimeserver={{ now_time_server }};</script> <script>let nowtimeserver={{ now_time_server }};</script>
<script>let ajax_toggle_url="{{ ajax_toggle_url }}";</script>
<style> <style>
.checking-now .last-checked { .checking-now .last-checked {
@ -100,59 +101,41 @@
{% endif %} {% endif %}
{% for watch in (watches|sort(attribute=sort_attribute, reverse=sort_order == 'asc'))|pagination_slice(skip=pagination.skip) %} {% for watch in (watches|sort(attribute=sort_attribute, reverse=sort_order == 'asc'))|pagination_slice(skip=pagination.skip) %}
{% set is_unviewed = watch.newest_history_key| int > watch.last_viewed and watch.history_n>=2 %}
{% set checking_now = is_checking_now(watch) %} {% set checking_now = is_checking_now(watch) %}
<tr id="{{ watch.uuid }}" <tr id="{{ watch.uuid }}" data-watch-uuid="{{ watch.uuid }}"
class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }} processor-{{ watch['processor'] }} class="{{ loop.cycle('pure-table-odd', 'pure-table-even') }} processor-{{ watch['processor'] }}
{% if watch.last_error is defined and watch.last_error != False %}error{% endif %} {# realtime.js also sets these vars on the row for update #}
{% if watch.last_notification_error is defined and watch.last_notification_error != False %}error{% endif %} {% if watch.compile_error_texts()|length >2 %}has-error{% endif %}
{% if watch.paused is defined and watch.paused != False %}paused{% endif %} {% if watch.paused is defined and watch.paused != False %}paused{% endif %}
{% if is_unviewed %}unviewed{% endif %} {% if watch.has_unviewed %}unviewed{% endif %}
{% if watch.has_restock_info %} has-restock-info {% if watch['restock']['in_stock'] %}in-stock{% else %}not-in-stock{% endif %} {% else %}no-restock-info{% endif %} {% if watch.has_restock_info %} has-restock-info {% if watch['restock']['in_stock'] %}in-stock{% else %}not-in-stock{% endif %} {% else %}no-restock-info{% endif %}
{% if watch.uuid in queued_uuids %}queued{% endif %} {% if watch.uuid in queued_uuids %}queued{% endif %}
{% if checking_now %}checking-now{% endif %} {% if checking_now %}checking-now{% endif %}
{% if watch.notification_muted %}notification_muted{% endif %}
"> ">
<td class="inline checkbox-uuid" ><input name="uuids" type="checkbox" value="{{ watch.uuid}} " > <span>{{ loop.index+pagination.skip }}</span></td> <td class="inline checkbox-uuid" ><input name="uuids" type="checkbox" value="{{ watch.uuid}} " > <span>{{ loop.index+pagination.skip }}</span></td>
<td class="inline watch-controls"> <td class="inline watch-controls">
{% if not watch.paused %} <a class="ajax-op state-off pause-toggle" data-op="pause" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks" class="icon icon-pause" ></a>
<a class="state-off" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='pause.svg')}}" alt="Pause checks" title="Pause checks" class="icon icon-pause" ></a> <a class="ajax-op state-on pause-toggle" data-op="pause" style="display: none" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="UnPause checks" title="UnPause checks" class="icon icon-unpause" ></a>
{% else %}
<a class="state-on" href="{{url_for('watchlist.index', op='pause', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='play.svg')}}" alt="UnPause checks" title="UnPause checks" class="icon icon-unpause" ></a> <a class="ajax-op state-off mute-toggle" data-op="mute" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notification" title="Mute notification" class="icon icon-mute" ></a>
{% endif %} <a class="ajax-op state-on mute-toggle" data-op="mute" style="display: none" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="UnMute notification" title="UnMute notification" class="icon icon-mute" ></a>
{% set mute_label = 'UnMute notification' if watch.notification_muted else 'Mute notification' %}
<a class="link-mute state-{{'on' if watch.notification_muted else 'off'}}" href="{{url_for('watchlist.index', op='mute', uuid=watch.uuid, tag=active_tag_uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="{{ mute_label }}" title="{{ mute_label }}" class="icon icon-mute" ></a>
</td> </td>
<td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}} <td class="title-col inline">{{watch.title if watch.title is not none and watch.title|length > 0 else watch.url}}
<a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}"></a> <a class="external" target="_blank" rel="noopener" href="{{ watch.link.replace('source:','') }}"></a>
<a class="link-spread" href="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="Create a link to share watch config with others" ></a> <a class="link-spread" href="{{url_for('ui.form_share_put_watch', uuid=watch.uuid)}}"><img src="{{url_for('static_content', group='images', filename='spread.svg')}}" class="status-icon icon icon-spread" title="Create a link to share watch config with others" ></a>
{% if watch.get_fetch_backend == "html_webdriver" {% if watch.get_fetch_backend == "html_webdriver"
or ( watch.get_fetch_backend == "system" and system_default_fetcher == 'html_webdriver' ) or ( watch.get_fetch_backend == "system" and system_default_fetcher == 'html_webdriver' )
or "extra_browser_" in watch.get_fetch_backend or "extra_browser_" in watch.get_fetch_backend
%} %}
<img class="status-icon" src="{{url_for('static_content', group='images', filename='google-chrome-icon.png')}}" alt="Using a Chrome browser" title="Using a Chrome browser" > <img class="status-icon" src="{{url_for('static_content', group='images', filename='google-chrome-icon.png')}}" alt="Using a Chrome browser" title="Using a Chrome browser" >
{% endif %} {% endif %}
{%if watch.is_pdf %}<img class="status-icon" src="{{url_for('static_content', group='images', filename='pdf-icon.svg')}}" title="Converting PDF to text" >{% endif %} {% if watch.is_pdf %}<img class="status-icon" src="{{url_for('static_content', group='images', filename='pdf-icon.svg')}}" alt="Converting PDF to text" >{% endif %}
{% if watch.has_browser_steps %}<img class="status-icon status-browsersteps" src="{{url_for('static_content', group='images', filename='steps.svg')}}" title="Browser Steps is enabled" >{% endif %} {% if watch.has_browser_steps %}<img class="status-icon status-browsersteps" src="{{url_for('static_content', group='images', filename='steps.svg')}}" alt="Browser Steps is enabled" >{% endif %}
{% if watch.last_error is defined and watch.last_error != False %}
<div class="fetch-error">{{ watch.last_error }}
{% if '403' in watch.last_error %} <div class="error-text" style="display:none;">{{ watch.compile_error_texts(has_proxies=datastore.proxy_list)|safe }}</div>
{% if has_proxies %}
<a href="{{ url_for('settings.settings_page', uuid=watch.uuid) }}#proxies">Try other proxies/location</a>&nbsp;
{% endif %}
<a href="{{ url_for('settings.settings_page', uuid=watch.uuid) }}#proxies">Try adding external proxies/locations</a>
{% endif %}
{% if 'empty result or contain only an image' in watch.last_error %}
<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Detecting-changes-in-images">more help here</a>.
{% endif %}
</div>
{% endif %}
{% if watch.last_notification_error is defined and watch.last_notification_error != False %}
<div class="fetch-error notification-error"><a href="{{url_for('settings.notification_logs')}}">{{ watch.last_notification_error }}</a></div>
{% endif %}
{% if watch['processor'] == 'text_json_diff' %} {% if watch['processor'] == 'text_json_diff' %}
{% if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data'] %} {% if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data'] %}
@ -190,12 +173,13 @@
</td> </td>
{% endif %} {% endif %}
{#last_checked becomes fetch-start-time#} {#last_checked becomes fetch-start-time#}
<td class="last-checked" data-timestamp="{{ watch.last_checked }}" {% if checking_now %} data-fetchduration={{ watch.fetch_time }} data-eta_complete="{{ watch.last_checked+watch.fetch_time }}" {% endif %} > <td class="last-checked" data-timestamp="{{ watch.last_checked }}" data-fetchduration={{ watch.fetch_time }} data-eta_complete="{{ watch.last_checked+watch.fetch_time }}" >
{% if checking_now %} <div class="spinner-wrapper" style="display:none;" >
<span class="spinner"></span><span> Checking now</span> <span class="spinner"></span><span>&nbsp;Checking now</span>
{% else %} </div>
{{watch|format_last_checked_time|safe}}</td> <span class="innertext">{{watch|format_last_checked_time|safe}}</span>
{% endif %} </td>
<td class="last-changed" data-timestamp="{{ watch.last_changed }}">{% if watch.history_n >=2 and watch.last_changed >0 %} <td class="last-changed" data-timestamp="{{ watch.last_changed }}">{% if watch.history_n >=2 and watch.last_changed >0 %}
{{watch.last_changed|format_timestamp_timeago}} {{watch.last_changed|format_timestamp_timeago}}
@ -204,15 +188,17 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
<a {% if watch.uuid in queued_uuids %}disabled="true"{% endif %} href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" <a href="" class="already-in-queue-button recheck pure-button pure-button-primary" style="display: none;" disabled="disabled">Queued</a>
class="recheck pure-button pure-button-primary">{% if watch.uuid in queued_uuids %}Queued{% else %}Recheck{% endif %}</a>
<a href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}" data-op='recheck' class="ajax-op recheck pure-button pure-button-primary">Recheck</a>
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="pure-button pure-button-primary">Edit</a> <a href="{{ url_for('ui.ui_edit.edit_page', uuid=watch.uuid, tag=active_tag_uuid)}}#general" class="pure-button pure-button-primary">Edit</a>
{% if watch.history_n >= 2 %} {% if watch.history_n >= 2 %}
{% set open_diff_in_new_tab = datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab') %} {% set open_diff_in_new_tab = datastore.data['settings']['application']['ui'].get('open_diff_in_new_tab') %}
{% set target_attr = ' target="' ~ watch.uuid ~ '"' if open_diff_in_new_tab else '' %} {% set target_attr = ' target="' ~ watch.uuid ~ '"' if open_diff_in_new_tab else '' %}
{% if is_unviewed %} {% if watch.has_unviewed %}
<a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid, from_version=watch.get_from_version_based_on_last_viewed) }}" {{target_attr}} class="pure-button pure-button-primary diff-link">History</a> <a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid, from_version=watch.get_from_version_based_on_last_viewed) }}" {{target_attr}} class="pure-button pure-button-primary diff-link">History</a>
{% else %} {% else %}
<a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary diff-link">History</a> <a href="{{ url_for('ui.ui_views.diff_history_page', uuid=watch.uuid)}}" {{target_attr}} class="pure-button pure-button-primary diff-link">History</a>
@ -229,18 +215,14 @@
</tbody> </tbody>
</table> </table>
<ul id="post-list-buttons"> <ul id="post-list-buttons">
{% if errored_count %} <li id="post-list-with-errors" class="{% if errored_count %}has-error{% endif %}" style="display: none;" >
<li> <a href="{{url_for('watchlist.index', with_errors=1, tag=request.args.get('tag')) }}" class="pure-button button-tag button-error">With errors ({{ errored_count }})</a>
<a href="{{url_for('watchlist.index', with_errors=1, tag=request.args.get('tag')) }}" class="pure-button button-tag button-error ">With errors ({{ errored_count }})</a>
</li> </li>
{% endif %} <li id="post-list-mark-views" class="{% if has_unviewed %}has-unviewed{% endif %}" style="display: none;" >
{% if has_unviewed %} <a href="{{url_for('ui.mark_all_viewed',with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag " id="mark-all-viewed">Mark all viewed</a>
<li>
<a href="{{url_for('ui.mark_all_viewed',with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag ">Mark all viewed</a>
</li> </li>
{% endif %}
<li> <li>
<a href="{{ url_for('ui.form_watch_checknow', tag=active_tag_uuid, with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag ">Recheck <a href="{{ url_for('ui.form_watch_checknow', tag=active_tag_uuid, with_errors=request.args.get('with_errors',0)) }}" class="pure-button button-tag" id="recheck-all">Recheck
all {% if active_tag_uuid %} in "{{active_tag.title}}"{%endif%}</a> all {% if active_tag_uuid %} in "{{active_tag.title}}"{%endif%}</a>
</li> </li>
<li> <li>
@ -251,4 +233,4 @@
</div> </div>
</form> </form>
</div> </div>
{% endblock %} {% endblock %}

Wyświetl plik

@ -0,0 +1,52 @@
import queue
from blinker import signal
from loguru import logger
class SignalPriorityQueue(queue.PriorityQueue):
"""
Extended PriorityQueue that sends a signal when items with a UUID are added.
This class extends the standard PriorityQueue and adds a signal emission
after an item is put into the queue. If the item contains a UUID, the signal
is sent with that UUID as a parameter.
"""
def __init__(self, maxsize=0):
super().__init__(maxsize)
try:
self.queue_length_signal = signal('queue_length')
except Exception as e:
logger.critical(f"Exception: {e}")
def put(self, item, block=True, timeout=None):
# Call the parent's put method first
super().put(item, block, timeout)
# After putting the item in the queue, check if it has a UUID and emit signal
if hasattr(item, 'item') and isinstance(item.item, dict) and 'uuid' in item.item:
uuid = item.item['uuid']
# Get the signal and send it if it exists
watch_check_update = signal('watch_check_update')
if watch_check_update:
# Send the watch_uuid parameter
watch_check_update.send(watch_uuid=uuid)
# Send queue_length signal with current queue size
try:
if self.queue_length_signal:
self.queue_length_signal.send(length=self.qsize())
except Exception as e:
logger.critical(f"Exception: {e}")
def get(self, block=True, timeout=None):
# Call the parent's get method first
item = super().get(block, timeout)
# Send queue_length signal with current queue size
try:
if self.queue_length_signal:
self.queue_length_signal.send(length=self.qsize())
except Exception as e:
logger.critical(f"Exception: {e}")
return item

Wyświetl plik

@ -7,9 +7,11 @@ import queue
import threading import threading
import time import time
import timeago import timeago
from blinker import signal
from changedetectionio.strtobool import strtobool from changedetectionio.strtobool import strtobool
from threading import Event from threading import Event
from changedetectionio.custom_queue import SignalPriorityQueue
from flask import ( from flask import (
Flask, Flask,
@ -25,9 +27,12 @@ from flask import (
) )
from flask_compress import Compress as FlaskCompress from flask_compress import Compress as FlaskCompress
from flask_login import current_user from flask_login import current_user
from flask_paginate import Pagination, get_page_parameter
from flask_restful import abort, Api from flask_restful import abort, Api
from flask_cors import CORS from flask_cors import CORS
# Create specific signals for application events
# Make this a global singleton to avoid multiple signal objects
watch_check_update = signal('watch_check_update', doc='Signal sent when a watch check is completed')
from flask_wtf import CSRFProtect from flask_wtf import CSRFProtect
from loguru import logger from loguru import logger
@ -45,7 +50,7 @@ ticker_thread = None
extra_stylesheets = [] extra_stylesheets = []
update_q = queue.PriorityQueue() update_q = SignalPriorityQueue()
notification_q = queue.Queue() notification_q = queue.Queue()
MAX_QUEUE_SIZE = 2000 MAX_QUEUE_SIZE = 2000
@ -54,6 +59,9 @@ app = Flask(__name__,
static_folder="static", static_folder="static",
template_folder="templates") template_folder="templates")
# Will be initialized in changedetection_app
socketio_server = None
# Enable CORS, especially useful for the Chrome extension to operate from anywhere # Enable CORS, especially useful for the Chrome extension to operate from anywhere
CORS(app) CORS(app)
@ -115,6 +123,18 @@ def get_darkmode_state():
def get_css_version(): def get_css_version():
return __version__ return __version__
@app.template_global()
def get_socketio_path():
"""Generate the correct Socket.IO path prefix for the client"""
# If behind a proxy with a sub-path, we need to respect that path
prefix = ""
if os.getenv('USE_X_SETTINGS') and 'X-Forwarded-Prefix' in request.headers:
prefix = request.headers['X-Forwarded-Prefix']
# Socket.IO will be available at {prefix}/socket.io/
return prefix
@app.template_filter('format_number_locale') @app.template_filter('format_number_locale')
def _jinja2_filter_format_number_locale(value: float) -> str: def _jinja2_filter_format_number_locale(value: float) -> str:
"Formats for example 4000.10 to the local locale default of 4,000.10" "Formats for example 4000.10 to the local locale default of 4,000.10"
@ -215,12 +235,15 @@ class User(flask_login.UserMixin):
def changedetection_app(config=None, datastore_o=None): def changedetection_app(config=None, datastore_o=None):
logger.trace("TRACE log is enabled") logger.trace("TRACE log is enabled")
global datastore global datastore, socketio_server
datastore = datastore_o datastore = datastore_o
# so far just for read-only via tests, but this will be moved eventually to be the main source # so far just for read-only via tests, but this will be moved eventually to be the main source
# (instead of the global var) # (instead of the global var)
app.config['DATASTORE'] = datastore_o app.config['DATASTORE'] = datastore_o
# Store the signal in the app config to ensure it's accessible everywhere
app.config['watch_check_update_SIGNAL'] = watch_check_update
login_manager = flask_login.LoginManager(app) login_manager = flask_login.LoginManager(app)
login_manager.login_view = 'login' login_manager.login_view = 'login'
@ -248,6 +271,9 @@ def changedetection_app(config=None, datastore_o=None):
# RSS access with token is allowed # RSS access with token is allowed
elif request.endpoint and 'rss.feed' in request.endpoint: elif request.endpoint and 'rss.feed' in request.endpoint:
return None return None
# Socket.IO routes - need separate handling
elif request.path.startswith('/socket.io/'):
return None
# API routes - use their own auth mechanism (@auth.check_token) # API routes - use their own auth mechanism (@auth.check_token)
elif request.path.startswith('/api/'): elif request.path.startswith('/api/'):
return None return None
@ -444,11 +470,17 @@ def changedetection_app(config=None, datastore_o=None):
# watchlist UI buttons etc # watchlist UI buttons etc
import changedetectionio.blueprint.ui as ui import changedetectionio.blueprint.ui as ui
app.register_blueprint(ui.construct_blueprint(datastore, update_q, running_update_threads, queuedWatchMetaData)) app.register_blueprint(ui.construct_blueprint(datastore, update_q, running_update_threads, queuedWatchMetaData, watch_check_update))
import changedetectionio.blueprint.watchlist as watchlist import changedetectionio.blueprint.watchlist as watchlist
app.register_blueprint(watchlist.construct_blueprint(datastore=datastore, update_q=update_q, queuedWatchMetaData=queuedWatchMetaData), url_prefix='') app.register_blueprint(watchlist.construct_blueprint(datastore=datastore, update_q=update_q, queuedWatchMetaData=queuedWatchMetaData), url_prefix='')
# Initialize Socket.IO server
from changedetectionio.realtime.socket_server import init_socketio
global socketio_server
socketio_server = init_socketio(app, datastore)
logger.info("Socket.IO server initialized")
# Memory cleanup endpoint # Memory cleanup endpoint
@app.route('/gc-cleanup', methods=['GET']) @app.route('/gc-cleanup', methods=['GET'])
@login_optionally_required @login_optionally_required
@ -467,6 +499,8 @@ def changedetection_app(config=None, datastore_o=None):
if not os.getenv("GITHUB_REF", False) and not strtobool(os.getenv('DISABLE_VERSION_CHECK', 'no')): if not os.getenv("GITHUB_REF", False) and not strtobool(os.getenv('DISABLE_VERSION_CHECK', 'no')):
threading.Thread(target=check_for_new_version).start() threading.Thread(target=check_for_new_version).start()
# Return the Flask app - the Socket.IO will be attached to it but initialized separately
# This avoids circular dependencies
return app return app
@ -502,48 +536,54 @@ def notification_runner():
global notification_debug_log global notification_debug_log
from datetime import datetime from datetime import datetime
import json import json
while not app.config.exit.is_set(): with app.app_context():
try: while not app.config.exit.is_set():
# At the moment only one thread runs (single runner)
n_object = notification_q.get(block=False)
except queue.Empty:
time.sleep(1)
else:
now = datetime.now()
sent_obj = None
try: try:
from changedetectionio.notification.handler import process_notification # At the moment only one thread runs (single runner)
n_object = notification_q.get(block=False)
except queue.Empty:
time.sleep(1)
# Fallback to system config if not set else:
if not n_object.get('notification_body') and datastore.data['settings']['application'].get('notification_body'):
n_object['notification_body'] = datastore.data['settings']['application'].get('notification_body')
if not n_object.get('notification_title') and datastore.data['settings']['application'].get('notification_title'): now = datetime.now()
n_object['notification_title'] = datastore.data['settings']['application'].get('notification_title') sent_obj = None
if not n_object.get('notification_format') and datastore.data['settings']['application'].get('notification_format'): try:
n_object['notification_format'] = datastore.data['settings']['application'].get('notification_format') from changedetectionio.notification.handler import process_notification
if n_object.get('notification_urls', {}):
sent_obj = process_notification(n_object, datastore)
except Exception as e: # Fallback to system config if not set
logger.error(f"Watch URL: {n_object['watch_url']} Error {str(e)}") if not n_object.get('notification_body') and datastore.data['settings']['application'].get('notification_body'):
n_object['notification_body'] = datastore.data['settings']['application'].get('notification_body')
# UUID wont be present when we submit a 'test' from the global settings if not n_object.get('notification_title') and datastore.data['settings']['application'].get('notification_title'):
if 'uuid' in n_object: n_object['notification_title'] = datastore.data['settings']['application'].get('notification_title')
datastore.update_watch(uuid=n_object['uuid'],
update_obj={'last_notification_error': "Notification error detected, goto notification log."}) if not n_object.get('notification_format') and datastore.data['settings']['application'].get('notification_format'):
n_object['notification_format'] = datastore.data['settings']['application'].get('notification_format')
if n_object.get('notification_urls', {}):
sent_obj = process_notification(n_object, datastore)
except Exception as e:
logger.error(f"Watch URL: {n_object['watch_url']} Error {str(e)}")
# UUID wont be present when we submit a 'test' from the global settings
if 'uuid' in n_object:
datastore.update_watch(uuid=n_object['uuid'],
update_obj={'last_notification_error': "Notification error detected, goto notification log."})
log_lines = str(e).splitlines()
notification_debug_log += log_lines
with app.app_context():
app.config['watch_check_update_SIGNAL'].send(app_context=app, watch_uuid=n_object.get('uuid'))
# Process notifications
notification_debug_log+= ["{} - SENDING - {}".format(now.strftime("%Y/%m/%d %H:%M:%S,000"), json.dumps(sent_obj))]
# Trim the log length
notification_debug_log = notification_debug_log[-100:]
log_lines = str(e).splitlines()
notification_debug_log += log_lines
# Process notifications
notification_debug_log+= ["{} - SENDING - {}".format(now.strftime("%Y/%m/%d %H:%M:%S,000"), json.dumps(sent_obj))]
# Trim the log length
notification_debug_log = notification_debug_log[-100:]
# Threaded runner, look for new watches to feed into the Queue. # Threaded runner, look for new watches to feed into the Queue.
def ticker_thread_check_time_launch_checks(): def ticker_thread_check_time_launch_checks():

Wyświetl plik

@ -1,3 +1,5 @@
from blinker import signal
from changedetectionio.strtobool import strtobool from changedetectionio.strtobool import strtobool
from changedetectionio.safe_jinja import render as jinja_render from changedetectionio.safe_jinja import render as jinja_render
from . import watch_base from . import watch_base
@ -41,6 +43,7 @@ class model(watch_base):
self.__datastore_path = kw.get('datastore_path') self.__datastore_path = kw.get('datastore_path')
if kw.get('datastore_path'): if kw.get('datastore_path'):
del kw['datastore_path'] del kw['datastore_path']
super(model, self).__init__(*arg, **kw) super(model, self).__init__(*arg, **kw)
if kw.get('default'): if kw.get('default'):
self.update(kw['default']) self.update(kw['default'])
@ -60,6 +63,10 @@ class model(watch_base):
return False return False
@property
def has_unviewed(self):
return int(self.newest_history_key) > int(self['last_viewed']) and self.__history_n >= 2
def ensure_data_dir_exists(self): def ensure_data_dir_exists(self):
if not os.path.isdir(self.watch_data_dir): if not os.path.isdir(self.watch_data_dir):
logger.debug(f"> Creating data dir {self.watch_data_dir}") logger.debug(f"> Creating data dir {self.watch_data_dir}")
@ -120,6 +127,10 @@ class model(watch_base):
'remote_server_reply': None, 'remote_server_reply': None,
'track_ldjson_price_data': None 'track_ldjson_price_data': None
}) })
watch_check_update = signal('watch_check_update')
if watch_check_update:
watch_check_update.send(watch_uuid=self.get('uuid'))
return return
@property @property
@ -648,3 +659,45 @@ class model(watch_base):
if step_n: if step_n:
available.append(step_n.group(1)) available.append(step_n.group(1))
return available return available
def compile_error_texts(self, has_proxies=None):
"""Compile error texts for this watch.
Accepts has_proxies parameter to ensure it works even outside app context"""
from flask import (
Markup, url_for
)
output = [] # Initialize as list since we're using append
last_error = self.get('last_error','')
try:
url_for('settings.settings_page')
except Exception as e:
has_app_context = False
else:
has_app_context = True
# has app+request context, we can use url_for()
if has_app_context:
if last_error:
if '403' in last_error:
if has_proxies:
output.append(str(Markup(f"{last_error} - <a href=\"{url_for('settings.settings_page', uuid=self.get('uuid'))}\">Try other proxies/location</a>&nbsp;'")))
else:
output.append(str(Markup(f"{last_error} - <a href=\"{url_for('settings.settings_page', uuid=self.get('uuid'))}\">Try adding external proxies/locations</a>&nbsp;'")))
else:
output.append(str(Markup(last_error)))
if self.get('last_notification_error'):
output.append(str(Markup(f"<div class=\"notification-error\"><a href=\"{url_for('settings.notification_logs')}\">{ self.get('last_notification_error') }</a></div>")))
else:
# Lo_Fi version
if last_error:
output.append(str(Markup(last_error)))
if self.get('last_notification_error'):
output.append(str(Markup(self.get('last_notification_error'))))
res = "\n".join(output)
return res

Wyświetl plik

@ -36,6 +36,7 @@ class watch_base(dict):
'include_filters': [], 'include_filters': [],
'last_checked': 0, 'last_checked': 0,
'last_error': False, 'last_error': False,
'last_notification_error': None,
'last_viewed': 0, # history key value of the last viewed via the [diff] link 'last_viewed': 0, # history key value of the last viewed via the [diff] link
'method': 'GET', 'method': 'GET',
'notification_alert_count': 0, 'notification_alert_count': 0,

Wyświetl plik

@ -2,10 +2,8 @@
import time import time
import apprise import apprise
from loguru import logger from loguru import logger
from .apprise_plugin.assets import apprise_asset, APPRISE_AVATAR_URL from .apprise_plugin.assets import apprise_asset, APPRISE_AVATAR_URL
def process_notification(n_object, datastore): def process_notification(n_object, datastore):
from changedetectionio.safe_jinja import render as jinja_render from changedetectionio.safe_jinja import render as jinja_render
from . import default_notification_format_for_watch, default_notification_format, valid_notification_formats from . import default_notification_format_for_watch, default_notification_format, valid_notification_formats

Wyświetl plik

@ -0,0 +1,3 @@
"""
Socket.IO realtime updates module for changedetection.io
"""

Wyświetl plik

@ -0,0 +1,283 @@
import timeago
from flask_socketio import SocketIO
import time
import os
from loguru import logger
from blinker import signal
from changedetectionio import strtobool
class SignalHandler:
"""A standalone class to receive signals"""
def __init__(self, socketio_instance, datastore):
self.socketio_instance = socketio_instance
self.datastore = datastore
# Connect to the watch_check_update signal
from changedetectionio.flask_app import watch_check_update as wcc
wcc.connect(self.handle_signal, weak=False)
logger.info("SignalHandler: Connected to signal from direct import")
# Connect to the queue_length signal
queue_length_signal = signal('queue_length')
queue_length_signal.connect(self.handle_queue_length, weak=False)
logger.info("SignalHandler: Connected to queue_length signal")
# Create and start the queue update thread using gevent
import gevent
logger.info("Using gevent for polling thread")
self.polling_emitter_thread = gevent.spawn(self.polling_emit_running_or_queued_watches)
# Store the thread reference in socketio for clean shutdown
self.socketio_instance.polling_emitter_thread = self.polling_emitter_thread
def handle_signal(self, *args, **kwargs):
logger.trace(f"SignalHandler: Signal received with {len(args)} args and {len(kwargs)} kwargs")
# Safely extract the watch UUID from kwargs
watch_uuid = kwargs.get('watch_uuid')
app_context = kwargs.get('app_context')
if watch_uuid:
# Get the watch object from the datastore
watch = self.datastore.data['watching'].get(watch_uuid)
if watch:
if app_context:
#note
with app_context.app_context():
with app_context.test_request_context():
# Forward to handle_watch_update with the watch parameter
handle_watch_update(self.socketio_instance, watch=watch, datastore=self.datastore)
else:
handle_watch_update(self.socketio_instance, watch=watch, datastore=self.datastore)
logger.info(f"Signal handler processed watch UUID {watch_uuid}")
else:
logger.warning(f"Watch UUID {watch_uuid} not found in datastore")
def handle_queue_length(self, *args, **kwargs):
"""Handle queue_length signal and emit to all clients"""
try:
queue_length = kwargs.get('length', 0)
logger.debug(f"SignalHandler: Queue length update received: {queue_length}")
# Emit the queue size to all connected clients
self.socketio_instance.emit("queue_size", {
"q_length": queue_length,
"event_timestamp": time.time()
})
except Exception as e:
logger.error(f"Socket.IO error in handle_queue_length: {str(e)}")
def polling_emit_running_or_queued_watches(self):
"""Greenlet that periodically updates the browser/frontend with current state of who is being checked or queued
This is because sometimes the browser page could reload (like on clicking on a link) but the data is old
"""
logger.info("Queue update greenlet started")
# Import the watch_check_update signal, update_q, and running_update_threads here to avoid circular imports
from changedetectionio.flask_app import app, running_update_threads
watch_check_update = signal('watch_check_update')
# Use gevent sleep for non-blocking operation
from gevent import sleep as gevent_sleep
# Get the stop event from the socketio instance
stop_event = self.socketio_instance.stop_event if hasattr(self.socketio_instance, 'stop_event') else None
# Run until explicitly stopped
while stop_event is None or not stop_event.is_set():
try:
# For each item in the queue, send a signal, so we update the UI
for t in running_update_threads:
if hasattr(t, 'current_uuid') and t.current_uuid:
logger.debug(f"Sending update for {t.current_uuid}")
# Send with app_context to ensure proper URL generation
with app.app_context():
watch_check_update.send(app_context=app, watch_uuid=t.current_uuid)
# Yield control back to gevent after each send to prevent blocking
gevent_sleep(0.1) # Small sleep to yield control
# Check if we need to stop in the middle of processing
if stop_event is not None and stop_event.is_set():
break
# Sleep between polling/update cycles
gevent_sleep(2)
except Exception as e:
logger.error(f"Error in queue update greenlet: {str(e)}")
# Sleep a bit to avoid flooding logs in case of persistent error
gevent_sleep(0.5)
logger.info("Queue update greenlet stopped")
def handle_watch_update(socketio, **kwargs):
"""Handle watch update signal from blinker"""
try:
watch = kwargs.get('watch')
datastore = kwargs.get('datastore')
# Emit the watch update to all connected clients
from changedetectionio.flask_app import running_update_threads, update_q
from changedetectionio.flask_app import _jinja2_filter_datetime
# Get list of watches that are currently running
running_uuids = []
for t in running_update_threads:
if hasattr(t, 'current_uuid') and t.current_uuid:
running_uuids.append(t.current_uuid)
# Get list of watches in the queue
queue_list = []
for q_item in update_q.queue:
if hasattr(q_item, 'item') and 'uuid' in q_item.item:
queue_list.append(q_item.item['uuid'])
error_texts = ""
# Get the error texts from the watch
error_texts = watch.compile_error_texts()
# Create a simplified watch data object to send to clients
watch_data = {
'checking_now': True if watch.get('uuid') in running_uuids else False,
'fetch_time': watch.get('fetch_time'),
'has_error': True if error_texts else False,
'last_changed': watch.get('last_changed'),
'last_checked': watch.get('last_checked'),
'error_text': error_texts,
'last_checked_text': _jinja2_filter_datetime(watch),
'last_changed_text': timeago.format(int(watch['last_changed']), time.time()) if watch.history_n >= 2 and int(watch.get('last_changed', 0)) > 0 else 'Not yet',
'queued': True if watch.get('uuid') in queue_list else False,
'paused': True if watch.get('paused') else False,
'notification_muted': True if watch.get('notification_muted') else False,
'unviewed': watch.has_unviewed,
'uuid': watch.get('uuid'),
'event_timestamp': time.time()
}
errored_count =0
for uuid, watch in datastore.data['watching'].items():
if watch.get('last_error'):
errored_count += 1
general_stats = {
'count_errors': errored_count,
'has_unviewed': datastore.has_unviewed
}
# Debug what's being emitted
#logger.debug(f"Emitting 'watch_update' event for {watch.get('uuid')}, data: {watch_data}")
# Emit to all clients (no 'broadcast' parameter needed - it's the default behavior)
socketio.emit("watch_update", {'watch': watch_data, 'general_stats': general_stats})
# Log after successful emit
#logger.info(f"Socket.IO: Emitted update for watch {watch.get('uuid')}, Checking now: {watch_data['checking_now']}")
except Exception as e:
logger.error(f"Socket.IO error in handle_watch_update: {str(e)}")
def init_socketio(app, datastore):
"""Initialize SocketIO with the main Flask app"""
# Use the threading async_mode instead of eventlet
# This avoids the need for monkey patching eventlet,
# Which leads to problems with async playwright etc
async_mode = 'gevent'
logger.info(f"Using {async_mode} mode for Socket.IO")
# Restrict SocketIO CORS to same origin by default, can be overridden with env var
cors_origins = os.environ.get('SOCKETIO_CORS_ORIGINS', None)
socketio = SocketIO(app,
async_mode=async_mode,
cors_allowed_origins=cors_origins, # None means same-origin only
logger=strtobool(os.getenv('SOCKETIO_LOGGING', 'False')),
engineio_logger=strtobool(os.getenv('SOCKETIO_LOGGING', 'False')))
# Set up event handlers
@socketio.on('connect')
def handle_connect():
"""Handle client connection"""
from changedetectionio.auth_decorator import login_optionally_required
from flask import request
from flask_login import current_user
from changedetectionio.flask_app import update_q
# Access datastore from socketio
datastore = socketio.datastore
# Check if authentication is required and user is not authenticated
has_password_enabled = datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False)
if has_password_enabled and not current_user.is_authenticated:
logger.warning("Socket.IO: Rejecting unauthenticated connection")
return False # Reject the connection
# Send the current queue size to the newly connected client
try:
queue_size = update_q.qsize()
socketio.emit("queue_size", {
"q_length": queue_size,
"event_timestamp": time.time()
}, room=request.sid) # Send only to this client
logger.debug(f"Socket.IO: Sent initial queue size {queue_size} to new client")
except Exception as e:
logger.error(f"Socket.IO error sending initial queue size: {str(e)}")
logger.info("Socket.IO: Client connected")
@socketio.on('disconnect')
def handle_disconnect():
"""Handle client disconnection"""
logger.info("Socket.IO: Client disconnected")
# Create a dedicated signal handler that will receive signals and emit them to clients
signal_handler = SignalHandler(socketio, datastore)
# Store the datastore reference on the socketio object for later use
socketio.datastore = datastore
# Create a stop event for our queue update thread using gevent Event
import gevent.event
stop_event = gevent.event.Event()
socketio.stop_event = stop_event
# Add a shutdown method to the socketio object
def shutdown():
"""Shutdown the SocketIO server gracefully"""
try:
logger.info("Socket.IO: Shutting down server...")
# Signal the queue update thread to stop
if hasattr(socketio, 'stop_event'):
socketio.stop_event.set()
logger.info("Socket.IO: Signaled queue update thread to stop")
# Wait for the greenlet to exit (with timeout)
if hasattr(socketio, 'polling_emitter_thread'):
try:
# For gevent greenlets
socketio.polling_emitter_thread.join(timeout=5)
logger.info("Socket.IO: Queue update greenlet joined successfully")
except Exception as e:
logger.error(f"Error joining greenlet: {str(e)}")
logger.info("Socket.IO: Queue update greenlet did not exit in time")
# Close any remaining client connections
#if hasattr(socketio, 'server'):
# socketio.server.disconnect()
logger.info("Socket.IO: Server shutdown complete")
except Exception as e:
logger.error(f"Socket.IO error during shutdown: {str(e)}")
# Attach the shutdown method to the socketio object
socketio.shutdown = shutdown
logger.info("Socket.IO initialized and attached to main Flask app")
return socketio

Wyświetl plik

@ -0,0 +1,115 @@
// Socket.IO client-side integration for changedetection.io
$(document).ready(function () {
function bindAjaxHandlerButtonsEvents() {
$('.ajax-op').on('click.ajaxHandlerNamespace', function (e) {
e.preventDefault();
$.ajax({
type: "POST",
url: ajax_toggle_url,
data: {'op': $(this).data('op'), 'uuid': $(this).closest('tr').data('watch-uuid')},
statusCode: {
400: function () {
// More than likely the CSRF token was lost when the server restarted
alert("There was a problem processing the request, please reload the page.");
}
}
});
return false;
});
}
// Only try to connect if authentication isn't required or user is authenticated
// The 'is_authenticated' variable will be set in the template
if (typeof is_authenticated !== 'undefined' ? is_authenticated : true) {
// Try to create the socket connection to the SocketIO server - if it fails, the site will still work normally
try {
// Connect to Socket.IO on the same host/port, with path from template
const socket = io({
path: socketio_url, // This will be the path prefix like "/app/socket.io" from the template
transports: ['polling', 'websocket'], // Try WebSocket but fall back to polling
reconnectionDelay: 1000,
reconnectionAttempts: 15
});
// Connection status logging
socket.on('connect', function () {
console.log('Socket.IO connected with path:', socketio_url);
console.log('Socket transport:', socket.io.engine.transport.name);
bindAjaxHandlerButtonsEvents();
});
socket.on('connect_error', function(error) {
console.error('Socket.IO connection error:', error);
});
socket.on('connect_timeout', function() {
console.error('Socket.IO connection timeout');
});
socket.on('error', function(error) {
console.error('Socket.IO error:', error);
});
socket.on('disconnect', function (reason) {
console.log('Socket.IO disconnected, reason:', reason);
$('.ajax-op').off('.ajaxHandlerNamespace')
});
socket.on('queue_size', function (data) {
console.log(`${data.event_timestamp} - Queue size update: ${data.q_length}`);
// Update queue size display if implemented in the UI
})
// Listen for periodically emitted watch data
console.log('Adding watch_update event listener');
socket.on('watch_update', function (data) {
const watch = data.watch;
const general_stats = data.general_stats;
// Log the entire watch object for debugging
console.log('!!! WATCH UPDATE EVENT RECEIVED !!!');
console.log(`${watch.event_timestamp} - Watch update ${watch.uuid} - Checking now - ${watch.checking_now} - UUID in URL ${window.location.href.includes(watch.uuid)}`);
console.log('Watch data:', watch);
console.log('General stats:', general_stats);
// Updating watch table rows
const $watchRow = $('tr[data-watch-uuid="' + watch.uuid + '"]');
console.log('Found watch row elements:', $watchRow.length);
if ($watchRow.length) {
$($watchRow).toggleClass('checking-now', watch.checking_now);
$($watchRow).toggleClass('queued', watch.queued);
$($watchRow).toggleClass('unviewed', watch.unviewed);
$($watchRow).toggleClass('has-error', watch.has_error);
$($watchRow).toggleClass('notification_muted', watch.notification_muted);
$($watchRow).toggleClass('paused', watch.paused);
$('td.title-col .error-text', $watchRow).html(watch.error_text)
$('td.last-changed', $watchRow).text(watch.last_checked_text)
$('td.last-checked .innertext', $watchRow).text(watch.last_checked_text)
$('td.last-checked', $watchRow).data('timestamp', watch.last_checked).data('fetchduration', watch.fetch_time);
$('td.last-checked', $watchRow).data('eta_complete', watch.last_checked + watch.fetch_time);
console.log('Updated UI for watch:', watch.uuid);
}
// Tabs at bottom of list
$('#post-list-mark-views').toggleClass("has-unviewed", general_stats.has_unviewed);
$('#post-list-with-errors').toggleClass("has-error", general_stats.count_errors !== 0)
$('#post-list-with-errors a').text(`xxxxWith errors (${ general_stats.count_errors })`);
$('body').toggleClass('checking-now', watch.checking_now && window.location.href.includes(watch.uuid));
});
} catch (e) {
// If Socket.IO fails to initialize, just log it and continue
console.log('Socket.IO initialization error:', e);
}
}
});

File diff suppressed because one or more lines are too long

Wyświetl plik

@ -68,7 +68,7 @@ $(function () {
if (eta_complete + 2 > nowtimeserver && fetch_duration > 3) { if (eta_complete + 2 > nowtimeserver && fetch_duration > 3) {
const remaining_seconds = Math.abs(eta_complete) - nowtimeserver - 1; const remaining_seconds = Math.abs(eta_complete) - nowtimeserver - 1;
let r = (1.0 - (remaining_seconds / fetch_duration)) * 100; let r = Math.round((1.0 - (remaining_seconds / fetch_duration)) * 100);
if (r < 10) { if (r < 10) {
r = 10; r = 10;
} }
@ -76,8 +76,8 @@ $(function () {
r = 100; r = 100;
} }
$(this).css('background-size', `${r}% 100%`); $(this).css('background-size', `${r}% 100%`);
//$(this).text(`${r}% remain ${remaining_seconds}`);
} else { } else {
// Snap to full complete
$(this).css('background-size', `100% 100%`); $(this).css('background-size', `100% 100%`);
} }
}); });

Wyświetl plik

@ -0,0 +1,31 @@
// Styles for Socket.IO real-time updates
body.checking-now {
#checking-now-fixed-tab {
display: block !important;
}
}
#checking-now-fixed-tab {
background: #ccc;
border-radius: 5px;
bottom: 0;
color: var(--color-text);
display: none;
font-size: 0.8rem;
left: 0;
padding: 5px;
position: fixed;
}
#post-list-buttons {
#post-list-with-errors.has-error {
display: inline-block !important;
}
#post-list-mark-views.has-unviewed {
display: inline-block !important;
}
}

Wyświetl plik

@ -0,0 +1,118 @@
/* table related */
.watch-table {
width: 100%;
font-size: 80%;
tr {
&.unviewed {
font-weight: bold;
}
color: var(--color-watch-table-row-text);
}
td {
white-space: nowrap;
&.title-col {
word-break: break-all;
white-space: normal;
}
}
th {
white-space: nowrap;
a {
font-weight: normal;
&.active {
font-weight: bolder;
}
&.inactive {
.arrow {
display: none;
}
}
}
}
.title-col a[target="_blank"]::after,
.current-diff-url::after {
content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);
margin: 0 3px 0 5px;
}
/* Row with 'checking-now' */
tr.checking-now {
td:first-child {
position: relative;
}
td:first-child::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 3px;
background-color: #293eff;
}
td.last-checked {
.spinner-wrapper {
display: inline-block !important;
}
.innertext {
display: none !important;
}
}
}
tr.queued {
a.recheck {
display: none !important;
}
a.already-in-queue-button {
display: inline-block !important;
}
}
tr.paused {
a.pause-toggle {
&.state-on {
display: inline !important;
}
&.state-off {
display: none !important;
}
}
}
tr.notification_muted {
a.mute-toggle {
&.state-on {
display: inline !important;
}
&.state-off {
display: none !important;
}
}
}
tr.has-error {
color: var(--color-watch-table-error);
.error-text {
display: block !important;
}
}
}

Wyświetl plik

@ -13,8 +13,10 @@
@import "parts/_menu"; @import "parts/_menu";
@import "parts/_love"; @import "parts/_love";
@import "parts/preview_text_filter"; @import "parts/preview_text_filter";
@import "parts/_watch_table";
@import "parts/_edit"; @import "parts/_edit";
@import "parts/_conditions_table"; @import "parts/_conditions_table";
@import "parts/_socket";
body { body {
color: var(--color-text); color: var(--color-text);
@ -169,56 +171,6 @@ code {
color: var(--color-text); color: var(--color-text);
} }
/* table related */
.watch-table {
width: 100%;
font-size: 80%;
tr {
&.unviewed {
font-weight: bold;
}
&.error {
color: var(--color-watch-table-error);
}
color: var(--color-watch-table-row-text);
}
td {
white-space: nowrap;
&.title-col {
word-break: break-all;
white-space: normal;
}
}
th {
white-space: nowrap;
a {
font-weight: normal;
&.active {
font-weight: bolder;
}
&.inactive {
.arrow {
display: none;
}
}
}
}
.title-col a[target="_blank"]::after,
.current-diff-url::after {
content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);
margin: 0 3px 0 5px;
}
}
.inline-tag { .inline-tag {
white-space: nowrap; white-space: nowrap;
border-radius: 5px; border-radius: 5px;

Wyświetl plik

@ -523,6 +523,63 @@ body.preview-text-enabled {
z-index: 3; z-index: 3;
box-shadow: 1px 1px 4px var(--color-shadow-jump); } box-shadow: 1px 1px 4px var(--color-shadow-jump); }
/* table related */
.watch-table {
width: 100%;
font-size: 80%;
/* Row with 'checking-now' */ }
.watch-table tr {
color: var(--color-watch-table-row-text); }
.watch-table tr.unviewed {
font-weight: bold; }
.watch-table td {
white-space: nowrap; }
.watch-table td.title-col {
word-break: break-all;
white-space: normal; }
.watch-table th {
white-space: nowrap; }
.watch-table th a {
font-weight: normal; }
.watch-table th a.active {
font-weight: bolder; }
.watch-table th a.inactive .arrow {
display: none; }
.watch-table .title-col a[target="_blank"]::after,
.watch-table .current-diff-url::after {
content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);
margin: 0 3px 0 5px; }
.watch-table tr.checking-now td:first-child {
position: relative; }
.watch-table tr.checking-now td:first-child::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 3px;
background-color: #293eff; }
.watch-table tr.checking-now td.last-checked .spinner-wrapper {
display: inline-block !important; }
.watch-table tr.checking-now td.last-checked .innertext {
display: none !important; }
.watch-table tr.queued a.recheck {
display: none !important; }
.watch-table tr.queued a.already-in-queue-button {
display: inline-block !important; }
.watch-table tr.paused a.pause-toggle.state-on {
display: inline !important; }
.watch-table tr.paused a.pause-toggle.state-off {
display: none !important; }
.watch-table tr.notification_muted a.mute-toggle.state-on {
display: inline !important; }
.watch-table tr.notification_muted a.mute-toggle.state-off {
display: none !important; }
.watch-table tr.has-error {
color: var(--color-watch-table-error); }
.watch-table tr.has-error .error-text {
display: block !important; }
ul#conditions_match_logic { ul#conditions_match_logic {
list-style: none; } list-style: none; }
ul#conditions_match_logic input, ul#conditions_match_logic label, ul#conditions_match_logic li { ul#conditions_match_logic input, ul#conditions_match_logic label, ul#conditions_match_logic li {
@ -623,6 +680,26 @@ ul#conditions_match_logic {
.fieldlist_formfields .addRuleRow:hover, .fieldlist_formfields .removeRuleRow:hover, .fieldlist_formfields .verifyRuleRow:hover { .fieldlist_formfields .addRuleRow:hover, .fieldlist_formfields .removeRuleRow:hover, .fieldlist_formfields .verifyRuleRow:hover {
background-color: #999; } background-color: #999; }
body.checking-now #checking-now-fixed-tab {
display: block !important; }
#checking-now-fixed-tab {
background: #ccc;
border-radius: 5px;
bottom: 0;
color: var(--color-text);
display: none;
font-size: 0.8rem;
left: 0;
padding: 5px;
position: fixed; }
#post-list-buttons #post-list-with-errors.has-error {
display: inline-block !important; }
#post-list-buttons #post-list-mark-views.has-unviewed {
display: inline-block !important; }
body { body {
color: var(--color-text); color: var(--color-text);
background: var(--color-background-page); background: var(--color-background-page);
@ -735,34 +812,6 @@ code {
background: var(--color-background-code); background: var(--color-background-code);
color: var(--color-text); } color: var(--color-text); }
/* table related */
.watch-table {
width: 100%;
font-size: 80%; }
.watch-table tr {
color: var(--color-watch-table-row-text); }
.watch-table tr.unviewed {
font-weight: bold; }
.watch-table tr.error {
color: var(--color-watch-table-error); }
.watch-table td {
white-space: nowrap; }
.watch-table td.title-col {
word-break: break-all;
white-space: normal; }
.watch-table th {
white-space: nowrap; }
.watch-table th a {
font-weight: normal; }
.watch-table th a.active {
font-weight: bolder; }
.watch-table th a.inactive .arrow {
display: none; }
.watch-table .title-col a[target="_blank"]::after,
.watch-table .current-diff-url::after {
content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);
margin: 0 3px 0 5px; }
.inline-tag, .watch-tag-list, .tracking-ldjson-price-data, .restock-label { .inline-tag, .watch-tag-list, .tracking-ldjson-price-data, .restock-label {
white-space: nowrap; white-space: nowrap;
border-radius: 5px; border-radius: 5px;

Wyświetl plik

@ -17,6 +17,7 @@ import threading
import time import time
import uuid as uuid_builder import uuid as uuid_builder
from loguru import logger from loguru import logger
from blinker import signal
from .processors import get_custom_watch_obj_for_processor from .processors import get_custom_watch_obj_for_processor
from .processors.restock_diff import Restock from .processors.restock_diff import Restock
@ -166,6 +167,10 @@ class ChangeDetectionStore:
self.data['watching'][uuid].update({'last_viewed': int(timestamp)}) self.data['watching'][uuid].update({'last_viewed': int(timestamp)})
self.needs_write = True self.needs_write = True
watch_check_update = signal('watch_check_update')
if watch_check_update:
watch_check_update.send(watch_uuid=uuid)
def remove_password(self): def remove_password(self):
self.__data['settings']['application']['password'] = False self.__data['settings']['application']['password'] = False
self.needs_write = True self.needs_write = True

Wyświetl plik

@ -28,9 +28,14 @@
<meta name="theme-color" content="#ffffff"> <meta name="theme-color" content="#ffffff">
<script> <script>
const csrftoken="{{ csrf_token() }}"; const csrftoken="{{ csrf_token() }}";
const socketio_url="{{ get_socketio_path() }}/socket.io";
const is_authenticated = {% if current_user.is_authenticated or not has_password %}true{% else %}false{% endif %};
</script> </script>
<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script> <script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='csrf.js')}}" defer></script> <script src="{{url_for('static_content', group='js', filename='csrf.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='socket.io.min.js')}}" integrity="sha384-c79GN5VsunZvi+Q/WObgk2in0CbZsHnjEqvFxC5DxHn9lTfNce2WW6h2pH6u/kF+" crossorigin="anonymous"></script>
<script src="{{url_for('static_content', group='js', filename='realtime.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='timeago-init.js')}}" defer></script>
</head> </head>
<body class=""> <body class="">
@ -227,6 +232,8 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</section> </section>
<script src="{{url_for('static_content', group='js', filename='toggle-theme.js')}}" defer></script> <script src="{{url_for('static_content', group='js', filename='toggle-theme.js')}}" defer></script>
<div id="checking-now-fixed-tab" style="display: none;"><span class="spinner"></span><span>&nbsp;Checking now</span></div>
</body> </body>
</html> </html>

Wyświetl plik

@ -0,0 +1,72 @@
import asyncio
import socketio
from aiohttp import web
SOCKETIO_URL = 'ws://localhost.localdomain:5005'
SOCKETIO_PATH = "/socket.io"
NUM_CLIENTS = 1
clients = []
shutdown_event = asyncio.Event()
class WatchClient:
def __init__(self, client_id: int):
self.client_id = client_id
self.i_got_watch_update_event = False
self.sio = socketio.AsyncClient(reconnection_attempts=50, reconnection_delay=1)
@self.sio.event
async def connect():
print(f"[Client {self.client_id}] Connected")
@self.sio.event
async def disconnect():
print(f"[Client {self.client_id}] Disconnected")
@self.sio.on("watch_update")
async def on_watch_update(watch):
self.i_got_watch_update_event = True
print(f"[Client {self.client_id}] Received update: {watch}")
async def run(self):
try:
await self.sio.connect(SOCKETIO_URL, socketio_path=SOCKETIO_PATH, transports=["websocket", "polling"])
await self.sio.wait()
except Exception as e:
print(f"[Client {self.client_id}] Connection error: {e}")
async def handle_check(request):
all_received = all(c.i_got_watch_update_event for c in clients)
result = "yes" if all_received else "no"
print(f"Received HTTP check — returning '{result}'")
shutdown_event.set() # Signal shutdown
return web.Response(text=result)
async def start_http_server():
app = web.Application()
app.add_routes([web.get('/did_all_clients_get_watch_update', handle_check)])
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '0.0.0.0', 6666)
await site.start()
async def main():
#await start_http_server()
for i in range(NUM_CLIENTS):
client = WatchClient(i)
clients.append(client)
asyncio.create_task(client.run())
await shutdown_event.wait()
print("Shutting down...")
# Graceful disconnect
for c in clients:
await c.sio.disconnect()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("Interrupted")

Wyświetl plik

@ -4,14 +4,14 @@ import time
from flask import url_for from flask import url_for
from .util import live_server_setup, wait_for_all_checks from .util import live_server_setup, wait_for_all_checks
# test pages with http://username@password:foobar.com/ work
def test_basic_auth(client, live_server, measure_memory_usage): def test_basic_auth(client, live_server, measure_memory_usage):
live_server_setup(live_server) live_server_setup(live_server)
# Add our URL to the import page
test_url = url_for('test_basicauth_method', _external=True).replace("//","//myuser:mypass@")
# This page will echo back any auth info
test_url = url_for('test_basicauth_method', _external=True).replace("//","//myuser:mypass@")
time.sleep(1)
res = client.post( res = client.post(
url_for("imports.import_page"), url_for("imports.import_page"),
data={"urls": test_url}, data={"urls": test_url},
@ -34,4 +34,4 @@ def test_basic_auth(client, live_server, measure_memory_usage):
follow_redirects=True follow_redirects=True
) )
assert b'myuser mypass basic' in res.data assert b'myuser mypass basic' in res.data

Wyświetl plik

@ -114,7 +114,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
# It should report nothing found (no new 'unviewed' class) # It should report nothing found (no new 'unviewed' class)
res = client.get(url_for("watchlist.index")) res = client.get(url_for("watchlist.index"))
assert b'unviewed' not in res.data assert b'unviewed' not in res.data
assert b'Mark all viewed' not in res.data assert b'class="has-unviewed' not in res.data
assert b'head title' not in res.data # Should not be present because this is off by default assert b'head title' not in res.data # Should not be present because this is off by default
assert b'test-endpoint' in res.data assert b'test-endpoint' in res.data
@ -133,7 +133,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
res = client.get(url_for("watchlist.index")) res = client.get(url_for("watchlist.index"))
assert b'unviewed' in res.data assert b'unviewed' in res.data
assert b'Mark all viewed' in res.data assert b'class="has-unviewed' in res.data
# It should have picked up the <title> # It should have picked up the <title>
assert b'head title' in res.data assert b'head title' in res.data
@ -144,7 +144,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
# hit the mark all viewed link # hit the mark all viewed link
res = client.get(url_for("ui.mark_all_viewed"), follow_redirects=True) res = client.get(url_for("ui.mark_all_viewed"), follow_redirects=True)
assert b'Mark all viewed' not in res.data assert b'class="has-unviewed' not in res.data
assert b'unviewed' not in res.data assert b'unviewed' not in res.data
# #2458 "clear history" should make the Watch object update its status correctly when the first snapshot lands again # #2458 "clear history" should make the Watch object update its status correctly when the first snapshot lands again

Wyświetl plik

@ -0,0 +1,139 @@
#!/usr/bin/env python3
import time
from flask import url_for
from .util import (
set_original_response,
set_modified_response,
live_server_setup,
wait_for_all_checks
)
from loguru import logger
def run_socketio_watch_update_test(client, live_server, password_mode=""):
"""Test that the socketio emits a watch update event when content changes"""
# Set up the test server
set_original_response()
# Get the SocketIO instance from the app
from changedetectionio.flask_app import app
socketio = app.extensions['socketio']
# Create a test client for SocketIO
socketio_test_client = socketio.test_client(app, flask_test_client=client)
if password_mode == "not logged in, should exit on connect":
assert not socketio_test_client.is_connected(), "Failed to connect to Socket.IO server because it should bounce this connect"
return
assert socketio_test_client.is_connected(), "Failed to connect to Socket.IO server"
print("Successfully connected to Socket.IO server")
# Add our URL to the import page
res = client.post(
url_for("imports.import_page"),
data={"urls": url_for('test_endpoint', _external=True)},
follow_redirects=True
)
assert b"1 Imported" in res.data
res = client.get(url_for("watchlist.index"))
assert url_for('test_endpoint', _external=True).encode() in res.data
# Wait for initial check to complete
wait_for_all_checks(client)
# Clear any initial messages
socketio_test_client.get_received()
# Make a change to trigger an update
set_modified_response()
# Force recheck
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
assert b'Queued 1 watch for rechecking.' in res.data
# Wait for the watch to be checked
wait_for_all_checks(client)
has_watch_update = False
has_unviewed_update = False
for i in range(10):
# Get received events
received = socketio_test_client.get_received()
if received:
logger.info(f"Received {len(received)} events after {i+1} seconds")
# Check for watch_update events with unviewed=True
for event in received:
if event['name'] == 'watch_update':
has_watch_update = True
if event['args'][0]['watch'].get('unviewed', False):
has_unviewed_update = True
logger.info("Found unviewed update event!")
break
if has_unviewed_update:
break
# Force a recheck every 5 seconds to ensure events are emitted
# if i > 0 and i % 5 == 0:
# print(f"Still waiting for events, forcing another recheck...")
# res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
# assert b'Queued 1 watch for rechecking.' in res.data
# wait_for_all_checks(client)
# print(f"Waiting for unviewed update event... {i+1}/{max_wait}")
time.sleep(1)
# Verify we received watch_update events
assert has_watch_update, "No watch_update events received"
# Verify we received an unviewed event
assert has_unviewed_update, "No watch_update event with unviewed=True received"
# Alternatively, check directly if the watch in the datastore is marked as unviewed
from changedetectionio.flask_app import app
datastore = app.config.get('DATASTORE')
watch_uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
# Get the watch from the datastore
watch = datastore.data['watching'].get(watch_uuid)
assert watch, f"Watch {watch_uuid} not found in datastore"
assert watch.has_unviewed, "The watch was not marked as unviewed after content change"
# Clean up
client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
def test_everything(live_server, client):
live_server_setup(live_server)
run_socketio_watch_update_test(password_mode="", live_server=live_server, client=client)
############################ Password required auth check ##############################
# Enable password check and diff page access bypass
res = client.post(
url_for("settings.settings_page"),
data={"application-password": "foobar",
"requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"},
follow_redirects=True
)
assert b"Password protection enabled." in res.data
run_socketio_watch_update_test(password_mode="not logged in, should exit on connect", live_server=live_server, client=client)
res = client.post(
url_for("login"),
data={"password": "foobar"},
follow_redirects=True
)
# Yes we are correctly logged in
assert b"LOG OUT" in res.data
run_socketio_watch_update_test(password_mode="should be like normal", live_server=live_server, client=client)

Wyświetl plik

@ -3,7 +3,7 @@
import time import time
from flask import url_for from flask import url_for
from .util import live_server_setup from .util import live_server_setup, wait_for_all_checks
def test_setup(live_server): def test_setup(live_server):
@ -70,19 +70,18 @@ def test_render_anchor_tag_content_true(client, live_server, measure_memory_usag
) )
assert b"1 Imported" in res.data assert b"1 Imported" in res.data
time.sleep(sleep_time_for_fetch_thread) wait_for_all_checks(client)
# Trigger a check # Trigger a check
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
# set a new html text with a modified link # set a new html text with a modified link
set_modified_ignore_response() set_modified_ignore_response()
time.sleep(sleep_time_for_fetch_thread) wait_for_all_checks(client)
# Trigger a check # Trigger a check
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
# Give the thread time to pick it up wait_for_all_checks(client)
time.sleep(sleep_time_for_fetch_thread)
# We should not see the rendered anchor tag # We should not see the rendered anchor tag
res = client.get(url_for("ui.ui_views.preview_page", uuid="first")) res = client.get(url_for("ui.ui_views.preview_page", uuid="first"))
@ -104,7 +103,7 @@ def test_render_anchor_tag_content_true(client, live_server, measure_memory_usag
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
# Give the thread time to pick it up # Give the thread time to pick it up
time.sleep(sleep_time_for_fetch_thread) wait_for_all_checks(client)

Wyświetl plik

@ -100,7 +100,7 @@ def test_check_basic_change_detection_functionality(client, live_server, measure
# A totally zero byte (#2528) response should also not trigger an error # A totally zero byte (#2528) response should also not trigger an error
set_zero_byte_response() set_zero_byte_response()
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
# 2877 # 2877
assert watch.last_changed == watch['last_checked'] assert watch.last_changed == watch['last_checked']

Wyświetl plik

@ -219,6 +219,15 @@ def test_rss_bad_chars_breaking(client, live_server):
rss_token = extract_rss_token_from_UI(client) rss_token = extract_rss_token_from_UI(client)
uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
i=0
from loguru import logger
# Because chardet could take a long time
while i<=10:
logger.debug(f"History was {live_server.app.config['DATASTORE'].data['watching'][uuid].history_n}..")
if live_server.app.config['DATASTORE'].data['watching'][uuid].history_n ==2:
break
i+=1
time.sleep(2)
assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n == 2 assert live_server.app.config['DATASTORE'].data['watching'][uuid].history_n == 2
# Check RSS feed is still working # Check RSS feed is still working

Wyświetl plik

@ -139,8 +139,7 @@ def wait_for_all_checks(client=None):
attempt = 0 attempt = 0
i=0 i=0
max_attempts = 60 max_attempts = 60
wait_between_attempts = 2 required_empty_duration = 0.2
required_empty_duration = 2
logger = logging.getLogger() logger = logging.getLogger()
time.sleep(1.2) time.sleep(1.2)
@ -329,6 +328,8 @@ def live_server_setup(live_server):
live_server.start() live_server.start()
def get_index(client): def get_index(client):
import inspect import inspect
# Get the caller's frame (parent function) # Get the caller's frame (parent function)

Wyświetl plik

@ -2,6 +2,7 @@ from .processors.exceptions import ProcessorException
import changedetectionio.content_fetchers.exceptions as content_fetchers_exceptions import changedetectionio.content_fetchers.exceptions as content_fetchers_exceptions
from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse
from changedetectionio import html_tools from changedetectionio import html_tools
from changedetectionio.flask_app import watch_check_update
import importlib import importlib
import os import os
@ -242,17 +243,16 @@ class update_worker(threading.Thread):
os.unlink(full_path) os.unlink(full_path)
def run(self): def run(self):
while not self.app.config.exit.is_set(): while not self.app.config.exit.is_set():
update_handler = None update_handler = None
watch = None
try: try:
queued_item_data = self.q.get(block=False) queued_item_data = self.q.get(block=False)
except queue.Empty: except queue.Empty:
pass pass
else: else:
uuid = queued_item_data.item.get('uuid') uuid = queued_item_data.item.get('uuid')
fetch_start_time = round(time.time()) # Also used for a unique history key for now fetch_start_time = round(time.time()) # Also used for a unique history key for now
self.current_uuid = uuid self.current_uuid = uuid
@ -272,6 +272,8 @@ class update_worker(threading.Thread):
logger.info(f"Processing watch UUID {uuid} Priority {queued_item_data.priority} URL {watch['url']}") logger.info(f"Processing watch UUID {uuid} Priority {queued_item_data.priority} URL {watch['url']}")
try: try:
watch_check_update.send(watch_uuid=uuid)
# Processor is what we are using for detecting the "Change" # Processor is what we are using for detecting the "Change"
processor = watch.get('processor', 'text_json_diff') processor = watch.get('processor', 'text_json_diff')
@ -588,12 +590,18 @@ class update_worker(threading.Thread):
'check_count': count 'check_count': count
}) })
self.current_uuid = None # Done self.current_uuid = None # Done
self.q.task_done() self.q.task_done()
# Send signal for watch check completion with the watch data
if watch:
logger.info(f"Sending watch_check_update signal for UUID {watch['uuid']}")
watch_check_update.send(watch_uuid=watch['uuid'])
update_handler = None update_handler = None
logger.debug(f"Watch {uuid} done in {time.time()-fetch_start_time:.2f}s") logger.debug(f"Watch {uuid} done in {time.time()-fetch_start_time:.2f}s")
# Give the CPU time to interrupt # Give the CPU time to interrupt
time.sleep(0.1) time.sleep(0.1)

Wyświetl plik

@ -9,6 +9,9 @@ flask_restful
flask_cors # For the Chrome extension to operate flask_cors # For the Chrome extension to operate
flask_wtf~=1.2 flask_wtf~=1.2
flask~=2.3 flask~=2.3
flask-socketio>=5.5.1
python-socketio>=5.13.0
python-engineio>=4.12.0
inscriptis~=2.2 inscriptis~=2.2
pytz pytz
timeago~=1.0 timeago~=1.0
@ -95,6 +98,9 @@ levenshtein
# Needed for > 3.10, https://github.com/microsoft/playwright-python/issues/2096 # Needed for > 3.10, https://github.com/microsoft/playwright-python/issues/2096
greenlet >= 3.0.3 greenlet >= 3.0.3
# Used for realtime socketio mode (so its a different driver to eventlet/threading not to interfere with playwright)
gevent
# Pinned or it causes problems with flask_expects_json which seems unmaintained # Pinned or it causes problems with flask_expects_json which seems unmaintained
referencing==0.35.1 referencing==0.35.1
@ -103,6 +109,8 @@ panzi-json-logic
# For conditions - extracted number from a body of text # For conditions - extracted number from a body of text
price-parser price-parser
# flask_socket_io - incorrect package name, already have flask-socketio above
# Scheduler - Windows seemed to miss a lot of default timezone info (even "UTC" !) # Scheduler - Windows seemed to miss a lot of default timezone info (even "UTC" !)
tzdata tzdata
@ -115,3 +123,6 @@ psutil==7.0.0
ruff >= 0.11.2 ruff >= 0.11.2
pre_commit >= 4.2.0 pre_commit >= 4.2.0
# For events between checking and socketio updates
blinker