This commit is contained in:
dgtlmoon
2025-05-07 17:47:11 +02:00
parent 7feeb3d1f6
commit 0c919e0edc
7 changed files with 112 additions and 113 deletions

View File

@@ -100,14 +100,13 @@
{% 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 }}" data-watch-uuid="{{ 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 %} {% if watch.last_error is defined and watch.last_error != False %}error{% endif %}
{% if watch.last_notification_error is defined and watch.last_notification_error != False %}error{% endif %} {% if watch.last_notification_error is defined and watch.last_notification_error != False %}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 %}
@@ -207,6 +206,7 @@
<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 {% if watch.uuid in queued_uuids %}disabled="true"{% endif %} href="{{ url_for('ui.form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}"
class="recheck pure-button pure-button-primary">{% if watch.uuid in queued_uuids %}Queued{% else %}Recheck{% endif %}</a> class="recheck pure-button pure-button-primary">{% if watch.uuid in queued_uuids %}Queued{% else %}Recheck{% endif %}</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 %}
@@ -214,7 +214,7 @@
{% 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>

View File

@@ -7,6 +7,7 @@ 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
@@ -28,6 +29,10 @@ from flask_login import current_user
from flask_paginate import Pagination, get_page_parameter 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_completed = signal('watch_check_completed', 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
import eventlet import eventlet
@@ -225,6 +230,9 @@ def changedetection_app(config=None, datastore_o=None):
# 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_COMPLETED_SIGNAL'] = watch_check_completed
login_manager = flask_login.LoginManager(app) login_manager = flask_login.LoginManager(app)
login_manager.login_view = 'login' login_manager.login_view = 'login'

View File

@@ -60,6 +60,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}")

View File

@@ -4,10 +4,43 @@ import threading
import json import json
import time import time
from loguru import logger from loguru import logger
import blinker
from changedetectionio.flask_app import _jinja2_filter_datetime from changedetectionio.flask_app import _jinja2_filter_datetime, watch_check_completed
class SignalHandler:
"""A standalone class to receive signals"""
def __init__(self, socketio_instance):
self.socketio_instance = socketio_instance
# Get signal from app config
app_signal = socketio_instance.main_app.config.get('WATCH_CHECK_COMPLETED_SIGNAL')
if app_signal:
app_signal.connect(self.handle_signal, weak=False)
logger.info("SignalHandler: Connected to signal from app config")
else:
# Fallback if not in app config
from changedetectionio.flask_app import watch_check_completed as wcc
wcc.connect(self.handle_signal, weak=False)
logger.info("SignalHandler: Connected to signal from direct import")
def handle_signal(self, *args, **kwargs):
logger.info(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')
if watch_uuid:
# Get the datastore from the socket instance
datastore = self.socketio_instance.datastore
# Get the watch object from the datastore
watch = datastore.data['watching'].get(watch_uuid)
if watch:
# Forward to the socket instance with the watch parameter
self.socketio_instance.handle_watch_update(watch=watch)
logger.info(f"Signal handler processed watch UUID {watch_uuid}")
else:
logger.warning(f"Watch UUID {watch_uuid} not found in datastore")
class ChangeDetectionSocketIO: class ChangeDetectionSocketIO:
def __init__(self, app, datastore): def __init__(self, app, datastore):
self.main_app = app self.main_app = app
@@ -28,76 +61,60 @@ class ChangeDetectionSocketIO:
# Just start a background thread to periodically emit watch status # Just start a background thread to periodically emit watch status
self.thread = None self.thread = None
self.thread_lock = threading.Lock() self.thread_lock = threading.Lock()
# Create a dedicated signal handler
self.signal_handler = SignalHandler(self)
def start_background_task(self):
"""Start the background task if it's not already running"""
with self.thread_lock:
if self.thread is None:
self.thread = threading.Thread(target=self.background_task)
self.thread.daemon = True
self.thread.start()
logger.info("Socket.IO: Started background task thread")
def handle_connect(self): def handle_connect(self):
"""Handle client connection""" """Handle client connection"""
logger.info("Socket.IO: Client connected") logger.info("Socket.IO: Client connected")
# Start the background task when the first client connects
self.start_background_task()
def handle_disconnect(self): def handle_disconnect(self):
"""Handle client disconnection""" """Handle client disconnection"""
logger.info("Socket.IO: Client disconnected") logger.info("Socket.IO: Client disconnected")
def background_task(self): def handle_watch_update(self, **kwargs):
"""Background task that emits watch status periodically""" """Handle watch update signal from blinker"""
check_interval = 4 # seconds between updates
try: try:
watch = kwargs.get('watch')
# Emit the watch update to all connected clients
with self.main_app.app_context(): with self.main_app.app_context():
while True: from changedetectionio.flask_app import running_update_threads, update_q
try:
# Collect all watch data # Get list of watches that are currently running
watches_data = [] running_uuids = []
for t in running_update_threads:
# Get list of watches that are currently running if hasattr(t, 'current_uuid') and t.current_uuid:
from changedetectionio.flask_app import running_update_threads running_uuids.append(t.current_uuid)
currently_checking = []
# Get list of watches in the queue
# Make a copy to avoid issues if the list changes queue_list = []
threads_snapshot = list(running_update_threads) for q_item in update_q.queue:
for thread in threads_snapshot: if hasattr(q_item, 'item') and 'uuid' in q_item.item:
if hasattr(thread, 'current_uuid') and thread.current_uuid: queue_list.append(q_item.item['uuid'])
currently_checking.append(thread.current_uuid)
self.socketio.emit("checking_now", list(currently_checking)) # Create a simplified watch data object to send to clients
watch_data = {
'uuid': watch.get('uuid'),
'last_checked_text': _jinja2_filter_datetime(watch),
'last_checked': watch.get('last_checked'),
'last_changed': watch.get('last_changed'),
'queued': True if watch.get('uuid') in queue_list else False,
'checking_now': True if watch.get('uuid') in running_uuids else False,
'unviewed': watch.has_unviewed,
}
self.socketio.emit("watch_update", watch_data)
logger.debug(f"Socket.IO: Emitted update for watch {watch.uuid}")
# Send all watch data periodically
for uuid, watch in self.datastore.data['watching'].items():
# Simplified watch data to avoid sending everything
simplified_data = {
'uuid': uuid,
'last_checked': _jinja2_filter_datetime(watch),
# 'history_n': watch.history_n if hasattr(watch, 'history_n') else 0,
}
#watches_data.append(simplified_data)
# Emit all watch data periodically
self.socketio.emit('watch_data', watches_data)
logger.debug(f"Socket.IO: Emitted watch data for {len(watches_data)} watches")
except Exception as e:
logger.error(f"Socket.IO error in background task: {str(e)}")
# Wait before next update
time.sleep(check_interval)
except Exception as e: except Exception as e:
logger.error(f"Socket.IO background task failed: {str(e)}") logger.error(f"Socket.IO error in handle_watch_update: {str(e)}")
def run(self, host='0.0.0.0', port=5005): def run(self, host='0.0.0.0', port=5005):
"""Run the Socket.IO server on a separate port""" """Run the Socket.IO server on a separate port"""
# Start the background task when the server starts # Start the background task when the server starts
self.start_background_task() #self.start_background_task()
# Run the Socket.IO server # Run the Socket.IO server
# Use 0.0.0.0 to listen on all interfaces # Use 0.0.0.0 to listen on all interfaces

View File

@@ -31,60 +31,18 @@ $(document).ready(function() {
}); });
// Listen for periodically emitted watch data // Listen for periodically emitted watch data
socket.on('watch_data', function(watches) { socket.on('watch_update', function(watch) {
/* console.log('Received watch data updates'); console.log(`Watch update ${watch.uuid}`);
// Update all watches with their current data const $watchRow = $('tr[data-watch-uuid="' + watch.uuid + '"]');
watches.forEach(function(watch) { if ($watchRow.length) {
const $watchRow = $('tr[data-watch-uuid="' + watch.uuid + '"]'); $($watchRow).toggleClass('checking-now', watch.checking_now);
if ($watchRow.length) { $($watchRow).toggleClass('queued', watch.queued);
updateWatchRow($watchRow, watch); $($watchRow).toggleClass('unviewed', watch.unviewed);
}
});*/
});
// Function to update a watch row with new data
function updateWatchRow($row, data) {
// Update the last-checked time
return;
const $lastChecked = $row.find('.last-checked');
if ($lastChecked.length) {
// Update data-timestamp attribute
$lastChecked.attr('data-timestamp', data.last_checked);
// Only show timeago if not currently checking
if (!data.checking) {
let $timeagoSpan = $lastChecked.find('.timeago');
// If there's no timeago span yet, create one
if (!$timeagoSpan.length) {
$lastChecked.html('<span class="timeago"></span>');
$timeagoSpan = $lastChecked.find('.timeago');
}
if (data.last_checked > 0) {
// Format as timeago if we have the timeago library available
if (typeof timeago !== 'undefined') {
$timeagoSpan.text(timeago.format(data.last_checked * 1000));
} else {
// Simple fallback if timeago isn't available
const date = new Date(data.last_checked * 1000);
$timeagoSpan.text(date.toLocaleString());
}
} else {
$lastChecked.text('Not yet');
}
}
} }
});
// Toggle the unviewed class based on viewed status
// $row.toggleClass('unviewed', data.unviewed_history === false);
// If the watch is currently being checked
// $row.toggleClass('checking-now', data.checking === true);
}
} catch (e) { } catch (e) {
// If Socket.IO fails to initialize, just log it and continue // If Socket.IO fails to initialize, just log it and continue
console.log('Socket.IO initialization error:', e); console.log('Socket.IO initialization error:', e);

View File

@@ -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_completed
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,9 @@ 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_completed.send(sender=self, watch=watch)
watch_check_completed.send(watch_uuid=watch['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 +591,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_completed signal for UUID {watch['uuid']}")
watch_check_completed.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)

View File

@@ -118,3 +118,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