diff --git a/MANIFEST.in b/MANIFEST.in
index 88601b4b..950c182d 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -5,6 +5,7 @@ recursive-include changedetectionio/conditions *
recursive-include changedetectionio/model *
recursive-include changedetectionio/notification *
recursive-include changedetectionio/processors *
+recursive-include changedetectionio/realtime *
recursive-include changedetectionio/static *
recursive-include changedetectionio/templates *
recursive-include changedetectionio/tests *
diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py
index e1f22dca..ffe7a2cd 100644
--- a/changedetectionio/__init__.py
+++ b/changedetectionio/__init__.py
@@ -7,14 +7,12 @@ __version__ = '0.49.17'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError
import os
-os.environ['EVENTLET_NO_GREENDNS'] = 'yes'
-import eventlet
-import eventlet.wsgi
import getopt
import platform
import signal
import socket
import sys
+from werkzeug.serving import run_simple
from changedetectionio import store
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')
datastore.sync_to_json()
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
app.config.exit.set()
sys.exit()
@@ -196,13 +203,85 @@ def main():
s_type = socket.AF_INET6 if ipv6_enabled else socket.AF_INET
- if ssl_mode:
- # @todo finalise SSL config, but this should get you in the right direction if you need it.
- eventlet.wsgi.server(eventlet.wrap_ssl(eventlet.listen((host, port), s_type),
- certfile='cert.pem',
- keyfile='privkey.pem',
- server_side=True), app)
+ # Get socketio_server from flask_app
+ from changedetectionio.flask_app import socketio_server
+ 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:
- 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
+ )
diff --git a/changedetectionio/blueprint/settings/templates/settings.html b/changedetectionio/blueprint/settings/templates/settings.html
index c45b267e..5f302331 100644
--- a/changedetectionio/blueprint/settings/templates/settings.html
+++ b/changedetectionio/blueprint/settings/templates/settings.html
@@ -246,6 +246,10 @@ nav
{{ render_checkbox_field(form.application.form.ui.form.open_diff_in_new_tab, class="open_diff_in_new_tab") }}
Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab.
+
diff --git a/changedetectionio/blueprint/ui/__init__.py b/changedetectionio/blueprint/ui/__init__.py
index fb685435..c9061bf7 100644
--- a/changedetectionio/blueprint/ui/__init__.py
+++ b/changedetectionio/blueprint/ui/__init__.py
@@ -3,12 +3,13 @@ from flask import Blueprint, request, redirect, url_for, flash, render_template,
from loguru import logger
from functools import wraps
+from changedetectionio.blueprint.ui.ajax import constuct_ui_ajax_blueprint
from changedetectionio.store import ChangeDetectionStore
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.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")
# Register the edit blueprint
@@ -20,9 +21,12 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
ui_blueprint.register_blueprint(notification_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_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
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')
else:
flash("Cleared snapshot history for watch {}".format(uuid))
-
return redirect(url_for('watchlist.index'))
@ui_blueprint.route("/clear_history", methods=['GET', 'POST'])
@@ -47,7 +50,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
if confirmtext == 'clear':
for uuid in datastore.data['watching'].keys():
datastore.clear_watch_history(uuid)
-
flash("Cleared snapshot history for all watches")
else:
flash('Incorrect confirmation text.', 'error')
@@ -153,53 +155,46 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat
@login_optionally_required
def form_watch_list_checkbox_operations():
op = request.form['op']
- uuids = request.form.getlist('uuids')
+ uuids = [u.strip() for u in request.form.getlist('uuids') if u]
if (op == 'delete'):
for uuid in uuids:
- uuid = uuid.strip()
if datastore.data['watching'].get(uuid):
- datastore.delete(uuid.strip())
+ datastore.delete(uuid)
flash("{} watches deleted".format(len(uuids)))
elif (op == 'pause'):
for uuid in uuids:
- uuid = uuid.strip()
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)))
elif (op == 'unpause'):
for uuid in uuids:
- uuid = uuid.strip()
if datastore.data['watching'].get(uuid):
datastore.data['watching'][uuid.strip()]['paused'] = False
flash("{} watches unpaused".format(len(uuids)))
elif (op == 'mark-viewed'):
for uuid in uuids:
- uuid = uuid.strip()
if datastore.data['watching'].get(uuid):
datastore.set_last_viewed(uuid, int(time.time()))
flash("{} watches updated".format(len(uuids)))
elif (op == 'mute'):
for uuid in uuids:
- uuid = uuid.strip()
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)))
elif (op == 'unmute'):
for uuid in uuids:
- uuid = uuid.strip()
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)))
elif (op == 'recheck'):
for uuid in uuids:
- uuid = uuid.strip()
if datastore.data['watching'].get(uuid):
# Recheck and require a full reprocessing
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'):
for uuid in uuids:
- uuid = uuid.strip()
if datastore.data['watching'].get(uuid):
datastore.data['watching'][uuid]["last_error"] = False
flash(f"{len(uuids)} watches errors cleared")
elif (op == 'clear-history'):
for uuid in uuids:
- uuid = uuid.strip()
if datastore.data['watching'].get(uuid):
datastore.clear_watch_history(uuid)
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
)
for uuid in uuids:
- uuid = uuid.strip()
if datastore.data['watching'].get(uuid):
- datastore.data['watching'][uuid.strip()]['notification_title'] = None
- datastore.data['watching'][uuid.strip()]['notification_body'] = None
- datastore.data['watching'][uuid.strip()]['notification_urls'] = []
- datastore.data['watching'][uuid.strip()]['notification_format'] = default_notification_format_for_watch
+ datastore.data['watching'][uuid]['notification_title'] = None
+ datastore.data['watching'][uuid]['notification_body'] = None
+ datastore.data['watching'][uuid]['notification_urls'] = []
+ datastore.data['watching'][uuid]['notification_format'] = default_notification_format_for_watch
flash("{} watches set to use default notification settings".format(len(uuids)))
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)
if op_extradata and tag_uuid:
for uuid in uuids:
- uuid = uuid.strip()
if datastore.data['watching'].get(uuid):
# Bug in old versions caused by bad edit page/tag handler
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")
+ if uuids:
+ for uuid in uuids:
+# with app.app_context():
+ watch_check_update.send(watch_uuid=uuid)
+
return redirect(url_for('watchlist.index'))
diff --git a/changedetectionio/blueprint/ui/ajax.py b/changedetectionio/blueprint/ui/ajax.py
new file mode 100644
index 00000000..bbe3464d
--- /dev/null
+++ b/changedetectionio/blueprint/ui/ajax.py
@@ -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
diff --git a/changedetectionio/blueprint/ui/views.py b/changedetectionio/blueprint/ui/views.py
index 15524e58..efcdc03a 100644
--- a/changedetectionio/blueprint/ui/views.py
+++ b/changedetectionio/blueprint/ui/views.py
@@ -8,7 +8,7 @@ from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required
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.route("/preview/", methods=['GET'])
diff --git a/changedetectionio/blueprint/watchlist/__init__.py b/changedetectionio/blueprint/watchlist/__init__.py
index 300eeebd..bd3b6c98 100644
--- a/changedetectionio/blueprint/watchlist/__init__.py
+++ b/changedetectionio/blueprint/watchlist/__init__.py
@@ -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")
sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title'])
+
output = render_template(
"watch-overview.html",
- active_tag=active_tag,
- active_tag_uuid=active_tag_uuid,
- app_rss_token=datastore.data['settings']['application'].get('rss_access_token'),
- datastore=datastore,
- errored_count=errored_count,
- form=form,
- guid=datastore.data['app_guid'],
- has_proxies=datastore.proxy_list,
- has_unviewed=datastore.has_unviewed,
- hosted_sticky=os.getenv("SALTED_PASS", False) == False,
- now_time_server=time.time(),
- pagination=pagination,
- queued_uuids=[q_uuid.item['uuid'] for q_uuid in update_q.queue],
- search_q=request.args.get('q', '').strip(),
- sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'),
- sort_order=request.args.get('order') if request.args.get('order') else request.cookies.get('order'),
- system_default_fetcher=datastore.data['settings']['application'].get('fetch_backend'),
- tags=sorted_tags,
- watches=sorted_watches
- )
+ active_tag=active_tag,
+ active_tag_uuid=active_tag_uuid,
+ app_rss_token=datastore.data['settings']['application'].get('rss_access_token'),
+ ajax_toggle_url=url_for('ui.ajax.ajax_toggler'),
+ datastore=datastore,
+ errored_count=errored_count,
+ form=form,
+ guid=datastore.data['app_guid'],
+ has_proxies=datastore.proxy_list,
+ has_unviewed=datastore.has_unviewed,
+ hosted_sticky=os.getenv("SALTED_PASS", False) == False,
+ now_time_server=round(time.time()),
+ pagination=pagination,
+ queued_uuids=[q_uuid.item['uuid'] for q_uuid in update_q.queue],
+ search_q=request.args.get('q', '').strip(),
+ sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'),
+ sort_order=request.args.get('order') if request.args.get('order') else request.cookies.get('order'),
+ system_default_fetcher=datastore.data['settings']['application'].get('fetch_backend'),
+ tags=sorted_tags,
+ watches=sorted_watches
+ )
if session.get('share-link'):
- del(session['share-link'])
+ del (session['share-link'])
resp = make_response(output)
diff --git a/changedetectionio/blueprint/watchlist/templates/watch-overview.html b/changedetectionio/blueprint/watchlist/templates/watch-overview.html
index e7896505..49fd2bd3 100644
--- a/changedetectionio/blueprint/watchlist/templates/watch-overview.html
+++ b/changedetectionio/blueprint/watchlist/templates/watch-overview.html
@@ -4,6 +4,7 @@
+