Compare commits

..

2 Commits

Author SHA1 Message Date
dgtlmoon
0c87e62e10 Test tweak 2026-01-13 16:21:01 +01:00
dgtlmoon
5b151c68e2 Possible RSS watch UUID bugfix 2026-01-13 16:11:19 +01:00
69 changed files with 5436 additions and 4737 deletions

View File

@@ -11,7 +11,6 @@ recursive-include changedetectionio/realtime *
recursive-include changedetectionio/static *
recursive-include changedetectionio/templates *
recursive-include changedetectionio/tests *
recursive-include changedetectionio/translations *
recursive-include changedetectionio/widgets *
prune changedetectionio/static/package-lock.json
prune changedetectionio/static/styles/node_modules

View File

@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
# Semver means never use .01, or 00. Should be .1.
__version__ = '0.52.6'
__version__ = '0.52.1'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError
@@ -41,10 +41,9 @@ from loguru import logger
#
# IMPLEMENTATION:
# 1. Explicit contexts everywhere (primary protection):
# - Watch.py: ctx = multiprocessing.get_context('spawn')
# - playwright.py: ctx = multiprocessing.get_context('spawn')
# - puppeteer.py: ctx = multiprocessing.get_context('spawn')
# - isolated_opencv.py: ctx = multiprocessing.get_context('spawn')
# - isolated_libvips.py: ctx = multiprocessing.get_context('spawn')
#
# 2. Global default (defense-in-depth, below):
# - Safety net if future code forgets explicit context

View File

@@ -438,8 +438,7 @@ class CreateWatch(Resource):
new_uuid = self.datastore.add_watch(url=url, extras=extras, tag=tags)
if new_uuid:
# Dont queue because the scheduler will check that it hasnt been checked before anyway
# worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
worker_handler.queue_item_async_safe(self.update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': new_uuid}))
return {'uuid': new_uuid}, 201
else:
return "Invalid or unsupported URL", 400

View File

@@ -1,4 +1,5 @@
from blinker import signal
from .processors.exceptions import ProcessorException
import changedetectionio.content_fetchers.exceptions as content_fetchers_exceptions
from changedetectionio.processors.text_json_diff.processor import FilterNotFoundInResponse
@@ -8,7 +9,7 @@ from changedetectionio.flask_app import watch_check_update
import asyncio
import importlib
import os
import sys
import queue
import time
from loguru import logger
@@ -16,51 +17,36 @@ from loguru import logger
# Async version of update_worker
# Processes jobs from AsyncSignalPriorityQueue instead of threaded queue
IN_PYTEST = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
DEFER_SLEEP_TIME_ALREADY_QUEUED = 0.3 if IN_PYTEST else 10.0
async def async_update_worker(worker_id, q, notification_q, app, datastore, executor=None):
async def async_update_worker(worker_id, q, notification_q, app, datastore):
"""
Async worker function that processes watch check jobs from the queue.
Args:
worker_id: Unique identifier for this worker
q: AsyncSignalPriorityQueue containing jobs to process
notification_q: Standard queue for notifications
app: Flask application instance
datastore: Application datastore
executor: ThreadPoolExecutor for queue operations (optional)
"""
# Set a descriptive name for this task
task = asyncio.current_task()
if task:
task.set_name(f"async-worker-{worker_id}")
logger.info(f"Starting async worker {worker_id}")
while not app.config.exit.is_set():
update_handler = None
watch = None
try:
# Use sync interface via run_in_executor since each worker has its own event loop
loop = asyncio.get_event_loop()
queued_item_data = await asyncio.wait_for(
loop.run_in_executor(executor, q.get, True, 1.0), # block=True, timeout=1.0
timeout=1.5
)
# Use native janus async interface - no threads needed!
queued_item_data = await asyncio.wait_for(q.async_get(), timeout=1.0)
except asyncio.TimeoutError:
# No jobs available, continue loop
continue
except Exception as e:
# Handle expected Empty exception from queue timeout
import queue
if isinstance(e, queue.Empty):
# Queue is empty, normal behavior - just continue
continue
# Unexpected exception - log as critical
logger.critical(f"CRITICAL: Worker {worker_id} failed to get queue item: {type(e).__name__}: {e}")
# Log queue health for debugging
@@ -82,7 +68,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
if worker_handler.is_watch_running_by_another_worker(uuid, worker_id):
logger.trace(f"Worker {worker_id} detected UUID {uuid} already being processed by another worker - deferring")
# Sleep to avoid tight loop and give the other worker time to finish
await asyncio.sleep(DEFER_SLEEP_TIME_ALREADY_QUEUED)
await asyncio.sleep(10.0)
# Re-queue with lower priority so it gets checked again after current processing finishes
deferred_priority = max(1000, queued_item_data.priority * 10)
@@ -428,13 +414,14 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
datastore.update_watch(uuid=uuid, update_obj={'last_error': f"Worker error: {str(e)}"})
finally:
try:
await update_handler.fetcher.quit(watch=watch)
except Exception as e:
logger.error(f"Exception while cleaning/quit after calling browser: {e}")
# Always cleanup - this runs whether there was an exception or not
if uuid:
try:
if update_handler and hasattr(update_handler, 'fetcher') and update_handler.fetcher:
await update_handler.fetcher.quit(watch=watch)
except Exception as e:
logger.error(f"Exception while cleaning/quit after calling browser: {e}")
try:
# Mark UUID as no longer being processed by this worker
worker_handler.set_uuid_processing(uuid, worker_id=worker_id, processing=False)
@@ -473,9 +460,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
logger.debug(f"Worker {worker_id} completed watch {uuid} in {time.time()-fetch_start_time:.2f}s")
except Exception as cleanup_error:
logger.error(f"Worker {worker_id} error during cleanup: {cleanup_error}")
del(uuid)
# Brief pause before continuing to avoid tight error loops (only on error)
if 'e' in locals():
await asyncio.sleep(1.0)

View File

@@ -92,12 +92,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
# Be sure we're written fresh
datastore.sync_to_json()
zip_thread = threading.Thread(
target=create_backup,
args=(datastore.datastore_path, datastore.data.get("watching")),
daemon=True,
name="BackupCreator"
)
zip_thread = threading.Thread(target=create_backup, args=(datastore.datastore_path, datastore.data.get("watching")))
zip_thread.start()
backup_threads.append(zip_thread)
flash(gettext("Backup building in background, check back in a few minutes."))

View File

@@ -21,154 +21,31 @@ from changedetectionio.flask_app import login_optionally_required
from loguru import logger
browsersteps_sessions = {}
browsersteps_watch_to_session = {} # Maps watch_uuid -> browsersteps_session_id
io_interface_context = None
import json
import hashlib
from flask import Response
import asyncio
import threading
import time
# Dedicated event loop for ALL browser steps sessions
_browser_steps_loop = None
_browser_steps_thread = None
_browser_steps_loop_lock = threading.Lock()
def _start_browser_steps_loop():
"""Start a dedicated event loop for browser steps in its own thread"""
global _browser_steps_loop
# Create and set the event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
_browser_steps_loop = loop
logger.debug("Browser steps event loop started")
try:
# Run the loop forever - handles all browsersteps sessions
loop.run_forever()
except Exception as e:
logger.error(f"Browser steps event loop error: {e}")
finally:
try:
# Cancel all remaining tasks
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
# Wait for tasks to finish cancellation
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
except Exception as e:
logger.debug(f"Error during browser steps loop cleanup: {e}")
finally:
loop.close()
logger.debug("Browser steps event loop closed")
def _ensure_browser_steps_loop():
"""Ensure the browser steps event loop is running"""
global _browser_steps_loop, _browser_steps_thread
with _browser_steps_loop_lock:
if _browser_steps_thread is None or not _browser_steps_thread.is_alive():
logger.debug("Starting browser steps event loop thread")
_browser_steps_thread = threading.Thread(
target=_start_browser_steps_loop,
daemon=True,
name="BrowserStepsEventLoop"
)
_browser_steps_thread.start()
# Wait for the loop to be ready
timeout = 5.0
start_time = time.time()
while _browser_steps_loop is None:
if time.time() - start_time > timeout:
raise RuntimeError("Browser steps event loop failed to start")
time.sleep(0.01)
logger.debug("Browser steps event loop thread started and ready")
def run_async_in_browser_loop(coro):
"""Run async coroutine using the dedicated browser steps event loop"""
_ensure_browser_steps_loop()
if _browser_steps_loop and not _browser_steps_loop.is_closed():
logger.debug("Browser steps using dedicated event loop")
future = asyncio.run_coroutine_threadsafe(coro, _browser_steps_loop)
"""Run async coroutine using the existing async worker event loop"""
from changedetectionio import worker_handler
# Use the existing async worker event loop instead of creating a new one
if worker_handler.USE_ASYNC_WORKERS and worker_handler.async_loop and not worker_handler.async_loop.is_closed():
logger.debug("Browser steps using existing async worker event loop")
future = asyncio.run_coroutine_threadsafe(coro, worker_handler.async_loop)
return future.result()
else:
raise RuntimeError("Browser steps event loop is not available")
def cleanup_expired_sessions():
"""Remove expired browsersteps sessions and cleanup their resources"""
global browsersteps_sessions, browsersteps_watch_to_session
expired_session_ids = []
# Find expired sessions
for session_id, session_data in browsersteps_sessions.items():
browserstepper = session_data.get('browserstepper')
if browserstepper and browserstepper.has_expired:
expired_session_ids.append(session_id)
# Cleanup expired sessions
for session_id in expired_session_ids:
logger.debug(f"Cleaning up expired browsersteps session {session_id}")
session_data = browsersteps_sessions[session_id]
# Cleanup playwright resources asynchronously
browserstepper = session_data.get('browserstepper')
if browserstepper:
try:
run_async_in_browser_loop(browserstepper.cleanup())
except Exception as e:
logger.error(f"Error cleaning up session {session_id}: {e}")
# Remove from sessions dict
del browsersteps_sessions[session_id]
# Remove from watch mapping
for watch_uuid, mapped_session_id in list(browsersteps_watch_to_session.items()):
if mapped_session_id == session_id:
del browsersteps_watch_to_session[watch_uuid]
break
if expired_session_ids:
logger.info(f"Cleaned up {len(expired_session_ids)} expired browsersteps session(s)")
def cleanup_session_for_watch(watch_uuid):
"""Cleanup a specific browsersteps session for a watch UUID"""
global browsersteps_sessions, browsersteps_watch_to_session
session_id = browsersteps_watch_to_session.get(watch_uuid)
if not session_id:
logger.debug(f"No browsersteps session found for watch {watch_uuid}")
return
logger.debug(f"Cleaning up browsersteps session {session_id} for watch {watch_uuid}")
session_data = browsersteps_sessions.get(session_id)
if session_data:
browserstepper = session_data.get('browserstepper')
if browserstepper:
try:
run_async_in_browser_loop(browserstepper.cleanup())
except Exception as e:
logger.error(f"Error cleaning up session {session_id} for watch {watch_uuid}: {e}")
# Remove from sessions dict
del browsersteps_sessions[session_id]
# Remove from watch mapping
del browsersteps_watch_to_session[watch_uuid]
logger.debug(f"Cleaned up session for watch {watch_uuid}")
# Opportunistically cleanup any other expired sessions
cleanup_expired_sessions()
# Fallback: create a new event loop (for sync workers or if async loop not available)
logger.debug("Browser steps creating temporary event loop")
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coro)
finally:
loop.close()
def construct_blueprint(datastore: ChangeDetectionStore):
browser_steps_blueprint = Blueprint('browser_steps', __name__, template_folder="templates")
@@ -246,9 +123,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
if not watch_uuid:
return make_response('No Watch UUID specified', 500)
# Cleanup any existing session for this watch
cleanup_session_for_watch(watch_uuid)
logger.debug("Starting connection with playwright")
logger.debug("browser_steps.py connecting")
@@ -257,10 +131,6 @@ def construct_blueprint(datastore: ChangeDetectionStore):
browsersteps_sessions[browsersteps_session_id] = run_async_in_browser_loop(
start_browsersteps_session(watch_uuid)
)
# Store the mapping of watch_uuid -> browsersteps_session_id
browsersteps_watch_to_session[watch_uuid] = browsersteps_session_id
except Exception as e:
if 'ECONNREFUSED' in str(e):
return make_response('Unable to start the Playwright Browser session, is sockpuppetbrowser running? Network configuration is OK?', 401)

View File

@@ -1,8 +1,13 @@
from flask import Blueprint, request, redirect, url_for, flash, render_template
from loguru import logger
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required
from changedetectionio import worker_handler
from changedetectionio.blueprint.imports.importer import (
import_url_list,
import_distill_io_json,
import_xlsx_wachete,
import_xlsx_custom
)
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
import_blueprint = Blueprint('imports', __name__, template_folder="templates")
@@ -12,26 +17,15 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
def import_page():
remaining_urls = []
from changedetectionio import forms
#
if request.method == 'POST':
# from changedetectionio import worker_handler
from changedetectionio.blueprint.imports.importer import (
import_url_list,
import_distill_io_json,
import_xlsx_wachete,
import_xlsx_custom
)
# URL List import
if request.values.get('urls') and len(request.values.get('urls').strip()):
# Import and push into the queue for immediate update check
importer_handler = import_url_list()
importer_handler.run(data=request.values.get('urls'), flash=flash, datastore=datastore, processor=request.values.get('processor', 'text_json_diff'))
logger.debug(f"Imported {len(importer_handler.new_uuids)} new UUIDs")
# Dont' add to queue because scheduler can see that they haven't been checked and will add them to the queue
# for uuid in importer_handler.new_uuids:
# worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
for uuid in importer_handler.new_uuids:
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
if len(importer_handler.remaining_data) == 0:
return redirect(url_for('watchlist.index'))
@@ -43,10 +37,8 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
# Import and push into the queue for immediate update check
d_importer = import_distill_io_json()
d_importer.run(data=request.values.get('distill-io'), flash=flash, datastore=datastore)
# Dont' add to queue because scheduler can see that they haven't been checked and will add them to the queue
# for uuid in importer_handler.new_uuids:
# worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
for uuid in d_importer.new_uuids:
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
# XLSX importer
if request.files and request.files.get('xlsx_file'):
@@ -68,10 +60,8 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
w_importer.import_profile = map
w_importer.run(data=file, flash=flash, datastore=datastore)
# Dont' add to queue because scheduler can see that they haven't been checked and will add them to the queue
# for uuid in importer_handler.new_uuids:
# worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
for uuid in w_importer.new_uuids:
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
# Could be some remaining, or we could be on GET
form = forms.importForm(formdata=request.form if request.method == 'POST' else None)

View File

@@ -50,8 +50,7 @@
<td>{{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}</td>
<td class="title-col inline"> <a href="{{url_for('watchlist.index', tag=uuid) }}">{{ tag.title }}</a></td>
<td>
<a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">{{ _('Edit') }}</a>
<a href="{{ url_for('ui.form_watch_checknow', tag=uuid) }}" class="pure-button pure-button-primary" >{{ _('Recheck') }}</a>
<a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">{{ _('Edit') }}</a>&nbsp;
<a class="pure-button button-error"
href="{{ url_for('tags.delete', uuid=uuid) }}"
data-requires-confirm

View File

@@ -1,5 +1,4 @@
import time
import threading
from flask import Blueprint, request, redirect, url_for, flash, render_template, session
from flask_babel import gettext
from loguru import logger
@@ -152,24 +151,9 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
confirmtext = request.form.get('confirmtext')
if confirmtext == 'clear':
# Run in background thread to avoid blocking
def clear_history_background():
# Capture UUIDs first to avoid race conditions
watch_uuids = list(datastore.data['watching'].keys())
logger.info(f"Background: Clearing history for {len(watch_uuids)} watches")
for uuid in watch_uuids:
try:
datastore.clear_watch_history(uuid)
except Exception as e:
logger.error(f"Error clearing history for watch {uuid}: {e}")
logger.info("Background: Completed clearing history")
# Start daemon thread
threading.Thread(target=clear_history_background, daemon=True).start()
flash(gettext("History clearing started in background"))
for uuid in datastore.data['watching'].keys():
datastore.clear_watch_history(uuid)
flash(gettext("Cleared snapshot history for all watches"))
else:
flash(gettext('Incorrect confirmation text.'), 'error')
@@ -243,9 +227,12 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
i = 0
running_uuids = worker_handler.get_running_uuids()
if uuid:
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
i += 1
if uuid not in running_uuids:
worker_handler.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
i += 1
else:
# Recheck all, including muted
@@ -254,7 +241,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_handle
watch_uuid = k[0]
watch = k[1]
if not watch['paused']:
if watch_uuid:
if watch_uuid not in running_uuids:
if with_errors and not watch.get('last_error'):
continue

View File

@@ -52,13 +52,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
redirect(url_for('ui_edit.edit_page', uuid=uuid))
# be sure we update with a copy instead of accidently editing the live object by reference
default = None
while not default:
try:
default = deepcopy(datastore.data['watching'][uuid])
except RuntimeError as e:
# Dictionary changed
continue
default = deepcopy(datastore.data['watching'][uuid])
# Defaults for proxy choice
if datastore.proxy_list is not None: # When enabled
@@ -244,13 +238,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
datastore.data['watching'][uuid] = watch_class(datastore_path=datastore.datastore_path, default=datastore.data['watching'][uuid])
flash(gettext("Updated watch - unpaused!") if request.args.get('unpause_on_save') else gettext("Updated watch."))
# Cleanup any browsersteps session for this watch
try:
from changedetectionio.blueprint.browser_steps import cleanup_session_for_watch
cleanup_session_for_watch(uuid)
except Exception as e:
logger.debug(f"Error cleaning up browsersteps session: {e}")
# Re #286 - We wait for syncing new data to disk in another thread every 60 seconds
# But in the case something is added we should save straight away
datastore.needs_write_urgent = True
@@ -338,6 +325,8 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
'url': url_for('rss.rss_single_watch', uuid=watch['uuid'], token=app_rss_token)
},
'settings_application': datastore.data['settings']['application'],
'system_has_playwright_configured': os.getenv('PLAYWRIGHT_DRIVER_URL'),
'system_has_webdriver_configured': os.getenv('WEBDRIVER_URL'),
'ui_edit_stats_extras': collect_ui_edit_stats_extras(watch),
'visual_selector_data_ready': datastore.visualselector_data_is_ready(watch_uuid=uuid),
'timezone_default_config': datastore.data['settings']['application'].get('scheduler_timezone_default'),

View File

@@ -206,7 +206,7 @@ Math: {{ 1 + 1 }}") }}
<div class="tab-pane-inner" id="browser-steps">
{% if capabilities.supports_browser_steps %}
{% if true %}
{% if visual_selector_data_ready %}
<img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality">
<fieldset>
<div class="pure-control-group">

View File

@@ -2,6 +2,7 @@ import os
import time
from flask import Blueprint, request, make_response, render_template, redirect, url_for, flash, session
from flask_login import current_user
from flask_paginate import Pagination, get_page_parameter
from changedetectionio import forms
@@ -84,7 +85,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
app_rss_token=datastore.data['settings']['application'].get('rss_access_token'),
datastore=datastore,
errored_count=errored_count,
extra_classes='has-queue' if not update_q.empty() else '',
form=form,
generate_tag_colors=processors.generate_processor_badge_colors,
guid=datastore.data['app_guid'],
@@ -92,11 +92,10 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
hosted_sticky=os.getenv("SALTED_PASS", False) == False,
now_time_server=round(time.time()),
pagination=pagination,
processor_badge_css=processors.get_processor_badge_css(),
processor_badge_texts=processors.get_processor_badge_texts(),
processor_descriptions=processors.get_processor_descriptions(),
queue_size=update_q.qsize(),
queued_uuids=update_q.get_queued_uuids(),
processor_badge_css=processors.get_processor_badge_css(),
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'),

View File

@@ -99,14 +99,9 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
data-confirm-message="{{ _('<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>') }}"
data-confirm-button="{{ _('Delete') }}"><i data-feather="trash" style="width: 14px; height: 14px; stroke: white; margin-right: 4px;"></i>{{ _('Delete') }}</button>
</div>
<div id="stats_row">
<div class="left">{%- if watches|length >= pagination.per_page -%}{{ pagination.info }}{%- endif -%}</div>
<div class="right" >{{ _('Queued size') }}: <span id="queue-size-int">{{ queue_size }}</span></div>
</div>
{%- if watches|length >= pagination.per_page -%}
{{ pagination.info }}
{%- endif -%}
{%- if search_q -%}<div id="search-result-info">{{ _('Searching') }} "<strong><i>{{search_q}}</i></strong>"</div>{%- endif -%}
<div>
<a href="{{url_for('watchlist.index')}}" class="pure-button button-tag {{'active' if not active_tag_uuid }}">{{ _('All') }}</a>
@@ -159,7 +154,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} {
<tbody>
{%- if not watches|length -%}
<tr>
<td colspan="{{ cols_required }}" style="text-wrap: wrap;">{{ _('No web page change detection watches configured, please add a URL in the box above, or') }} <a href="{{ url_for('imports.import_page')}}" >{{ _('import a list') }}</a>.</td>
<td colspan="{{ cols_required }}" style="text-wrap: wrap;">{{ _('No website watches configured, please add a URL in the box above, or') }} <a href="{{ url_for('imports.import_page')}}" >{{ _('import a list') }}</a>.</td>
</tr>
{%- endif -%}

View File

@@ -1,4 +1,3 @@
import gc
import json
import os
from urllib.parse import urlparse
@@ -186,33 +185,20 @@ class fetcher(Fetcher):
super().screenshot_step(step_n=step_n)
screenshot = await capture_full_page_async(page=self.page, screenshot_format=self.screenshot_format)
# Request GC immediately after screenshot to free memory
# Screenshots can be large and browser steps take many of them
await self.page.request_gc()
if self.browser_steps_screenshot_path is not None:
destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.jpeg'.format(step_n))
logger.debug(f"Saving step screenshot to {destination}")
with open(destination, 'wb') as f:
f.write(screenshot)
# Clear local reference to allow screenshot bytes to be collected
del screenshot
gc.collect()
async def save_step_html(self, step_n):
super().save_step_html(step_n=step_n)
content = await self.page.content()
# Request GC after getting page content
await self.page.request_gc()
destination = os.path.join(self.browser_steps_screenshot_path, 'step_{}.html'.format(step_n))
logger.debug(f"Saving step HTML to {destination}")
with open(destination, 'w', encoding='utf-8') as f:
f.write(content)
# Clear local reference
del content
gc.collect()
async def run(self,
fetch_favicon=True,
@@ -319,12 +305,6 @@ class fetcher(Fetcher):
if self.status_code != 200 and not ignore_status_codes:
screenshot = await capture_full_page_async(self.page, screenshot_format=self.screenshot_format)
# Cleanup before raising to prevent memory leak
await self.page.close()
await context.close()
await browser.close()
# Force garbage collection to release Playwright resources immediately
gc.collect()
raise Non200ErrorCodeReceived(url=url, status_code=self.status_code, screenshot=screenshot)
if not empty_pages_are_a_change and len((await self.page.content()).strip()) == 0:
@@ -333,52 +313,48 @@ class fetcher(Fetcher):
await browser.close()
raise EmptyReply(url=url, status_code=response.status)
# Wrap remaining operations in try/finally to ensure cleanup
# Run Browser Steps here
if self.browser_steps_get_valid_steps():
await self.iterate_browser_steps(start_url=url)
await self.page.wait_for_timeout(extra_wait * 1000)
now = time.time()
# So we can find an element on the page where its selector was entered manually (maybe not xPath etc)
if current_include_filters is not None:
await self.page.evaluate("var include_filters={}".format(json.dumps(current_include_filters)))
else:
await self.page.evaluate("var include_filters=''")
await self.page.request_gc()
# request_gc before and after evaluate to free up memory
# @todo browsersteps etc
MAX_TOTAL_HEIGHT = int(os.getenv("SCREENSHOT_MAX_HEIGHT", SCREENSHOT_MAX_HEIGHT_DEFAULT))
self.xpath_data = await self.page.evaluate(XPATH_ELEMENT_JS, {
"visualselector_xpath_selectors": visualselector_xpath_selectors,
"max_height": MAX_TOTAL_HEIGHT
})
await self.page.request_gc()
self.instock_data = await self.page.evaluate(INSTOCK_DATA_JS)
await self.page.request_gc()
self.content = await self.page.content()
await self.page.request_gc()
logger.debug(f"Scrape xPath element data in browser done in {time.time() - now:.2f}s")
# Bug 3 in Playwright screenshot handling
# Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
# JPEG is better here because the screenshots can be very very large
# Screenshots also travel via the ws:// (websocket) meaning that the binary data is base64 encoded
# which will significantly increase the IO size between the server and client, it's recommended to use the lowest
# acceptable screenshot quality here
try:
# Run Browser Steps here
if self.browser_steps_get_valid_steps():
await self.iterate_browser_steps(start_url=url)
await self.page.wait_for_timeout(extra_wait * 1000)
now = time.time()
# So we can find an element on the page where its selector was entered manually (maybe not xPath etc)
if current_include_filters is not None:
await self.page.evaluate("var include_filters={}".format(json.dumps(current_include_filters)))
else:
await self.page.evaluate("var include_filters=''")
await self.page.request_gc()
# request_gc before and after evaluate to free up memory
# @todo browsersteps etc
MAX_TOTAL_HEIGHT = int(os.getenv("SCREENSHOT_MAX_HEIGHT", SCREENSHOT_MAX_HEIGHT_DEFAULT))
self.xpath_data = await self.page.evaluate(XPATH_ELEMENT_JS, {
"visualselector_xpath_selectors": visualselector_xpath_selectors,
"max_height": MAX_TOTAL_HEIGHT
})
await self.page.request_gc()
self.instock_data = await self.page.evaluate(INSTOCK_DATA_JS)
await self.page.request_gc()
self.content = await self.page.content()
await self.page.request_gc()
logger.debug(f"Scrape xPath element data in browser done in {time.time() - now:.2f}s")
# Bug 3 in Playwright screenshot handling
# Some bug where it gives the wrong screenshot size, but making a request with the clip set first seems to solve it
# JPEG is better here because the screenshots can be very very large
# Screenshots also travel via the ws:// (websocket) meaning that the binary data is base64 encoded
# which will significantly increase the IO size between the server and client, it's recommended to use the lowest
# acceptable screenshot quality here
# The actual screenshot - this always base64 and needs decoding! horrible! huge CPU usage
self.screenshot = await capture_full_page_async(page=self.page, screenshot_format=self.screenshot_format)
except ScreenshotUnavailable:
# Re-raise screenshot unavailable exceptions
raise
except Exception as e:
# It's likely the screenshot was too long/big and something crashed
raise ScreenshotUnavailable(url=url, status_code=self.status_code)
@@ -413,10 +389,6 @@ class fetcher(Fetcher):
pass
browser = None
# Force Python GC to release Playwright resources immediately
# Playwright objects can have circular references that delay cleanup
gc.collect()
# Plugin registration for built-in fetcher
class PlaywrightFetcherPlugin:

View File

@@ -15,7 +15,7 @@ class fetcher(Fetcher):
proxy_url = None
# Capability flags
supports_browser_steps = False
supports_browser_steps = True
supports_screenshots = True
supports_xpath_element_data = True
@@ -156,19 +156,6 @@ class fetcher(Fetcher):
from PIL import Image
import io
img = Image.open(io.BytesIO(screenshot_png))
# Convert to RGB if needed (JPEG doesn't support transparency)
# Always convert non-RGB modes to RGB to ensure JPEG compatibility
if img.mode in ('RGBA', 'LA', 'P', 'PA'):
# Handle transparency by compositing onto white background
if img.mode == 'P':
img = img.convert('RGBA')
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode in ('RGBA', 'LA', 'PA'):
background.paste(img, mask=img.split()[-1]) # Use alpha channel as mask
img = background
elif img.mode != 'RGB':
# For other modes, direct conversion
img = img.convert('RGB')
jpeg_buffer = io.BytesIO()
img.save(jpeg_buffer, format='JPEG', quality=int(os.getenv("SCREENSHOT_QUALITY", 72)))
self.screenshot = jpeg_buffer.getvalue()

View File

@@ -57,15 +57,14 @@ class SignalPriorityQueue(queue.PriorityQueue):
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:
# NOTE: This would block other workers from .put/.get while this signal sends
# Signal handlers may iterate the queue/datastore while holding locks
# Send the watch_uuid parameter
watch_check_update.send(watch_uuid=uuid)
# Send queue_length signal with current queue size
@@ -313,15 +312,14 @@ class AsyncSignalPriorityQueue(asyncio.PriorityQueue):
async def put(self, item):
# Call the parent's put method first
await super().put(item)
# 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:
# NOTE: This would block other workers from .put/.get while this signal sends
# Signal handlers may iterate the queue/datastore while holding locks
# Send the watch_uuid parameter
watch_check_update.send(watch_uuid=uuid)
# Send queue_length signal with current queue size

View File

@@ -9,7 +9,6 @@ import threading
import time
import timeago
from blinker import signal
from pathlib import Path
from changedetectionio.strtobool import strtobool
from threading import Event
@@ -27,7 +26,9 @@ from flask import (
session,
url_for,
)
from urllib.parse import urlparse
from flask_compress import Compress as FlaskCompress
from flask_login import current_user
from flask_restful import abort, Api
from flask_cors import CORS
@@ -44,7 +45,6 @@ from changedetectionio.api import Watch, WatchHistory, WatchSingleHistory, Watch
from changedetectionio.api.Search import Search
from .time_handler import is_within_schedule
from changedetectionio.languages import get_available_languages, get_language_codes, get_flag_for_locale, get_timeago_locale
IN_PYTEST = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
datastore = None
@@ -55,7 +55,7 @@ extra_stylesheets = []
# Use bulletproof janus-based queues for sync/async reliability
update_q = RecheckPriorityQueue()
notification_q = NotificationQueue()
MAX_QUEUE_SIZE = 5000
MAX_QUEUE_SIZE = 2000
app = Flask(__name__,
static_url_path="",
@@ -84,10 +84,6 @@ app.config['NEW_VERSION_AVAILABLE'] = False
if os.getenv('FLASK_SERVER_NAME'):
app.config['SERVER_NAME'] = os.getenv('FLASK_SERVER_NAME')
# Babel/i18n configuration
app.config['BABEL_TRANSLATION_DIRECTORIES'] = str(Path(__file__).parent / 'translations')
app.config['BABEL_DEFAULT_LOCALE'] = 'en_GB'
#app.config["EXPLAIN_TEMPLATE_LOADING"] = True
@@ -399,9 +395,13 @@ def changedetection_app(config=None, datastore_o=None):
def get_locale():
# 1. Try to get locale from session (user explicitly selected)
if 'locale' in session:
return session['locale']
locale = session['locale']
logger.trace(f"DEBUG: get_locale() returning from session: {locale}")
return locale
# 2. Fall back to Accept-Language header
return request.accept_languages.best_match(language_codes)
locale = request.accept_languages.best_match(language_codes)
logger.trace(f"DEBUG: get_locale() returning from Accept-Language: {locale}")
return locale
# Initialize Babel with locale selector
babel = Babel(app, locale_selector=get_locale)
@@ -518,20 +518,9 @@ def changedetection_app(config=None, datastore_o=None):
@app.route('/set-language/<locale>')
def set_language(locale):
"""Set the user's preferred language in the session"""
if not request.cookies:
logger.error("Cannot set language without session cookie")
flash("Cannot set language without session cookie", 'error')
return redirect(url_for('watchlist.index'))
# Validate the locale against available languages
if locale in language_codes:
session['locale'] = locale
# CRITICAL: Flask-Babel caches the locale in the request context (ctx.babel_locale)
# We must refresh to clear this cache so the new locale takes effect immediately
# This is especially important for tests where multiple requests happen rapidly
from flask_babel import refresh
refresh()
else:
logger.error(f"Invalid locale {locale}, available: {language_codes}")
@@ -874,13 +863,13 @@ def changedetection_app(config=None, datastore_o=None):
worker_handler.start_workers(n_workers, update_q, notification_q, app, datastore)
# @todo handle ctrl break
ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks, daemon=True, name="TickerThread-ScheduleChecker").start()
threading.Thread(target=notification_runner, daemon=True, name="NotificationRunner").start()
ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start()
threading.Thread(target=notification_runner).start()
in_pytest = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
# Check for new release version, but not when running in test/build or pytest
if not os.getenv("GITHUB_REF", False) and not strtobool(os.getenv('DISABLE_VERSION_CHECK', 'no')) and not in_pytest:
threading.Thread(target=check_for_new_version, daemon=True, name="VersionChecker").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
@@ -978,10 +967,6 @@ def ticker_thread_check_time_launch_checks():
logger.debug(f"System env MINIMUM_SECONDS_RECHECK_TIME {recheck_time_minimum_seconds}")
# Workers are now started during app initialization, not here
WAIT_TIME_BETWEEN_LOOP = 1.0 if not IN_PYTEST else 0.01
if IN_PYTEST:
# The time between loops should be less than the first .sleep/wait in def wait_for_all_checks() of tests/util.py
logger.warning(f"Looks like we're in PYTEST! Setting time between searching for items to add to the queue to {WAIT_TIME_BETWEEN_LOOP}s")
while not app.config.exit.is_set():
@@ -1005,9 +990,6 @@ def ticker_thread_check_time_launch_checks():
# Get a list of watches by UUID that are currently fetching data
running_uuids = worker_handler.get_running_uuids()
# Build set of queued UUIDs once for O(1) lookup instead of O(n) per watch
queued_uuids = {q_item.item['uuid'] for q_item in update_q.queue}
# Re #232 - Deepcopy the data incase it changes while we're iterating through it all
watch_uuid_list = []
while True:
@@ -1024,17 +1006,16 @@ def ticker_thread_check_time_launch_checks():
else:
break
# Re #438 - Don't place more watches in the queue to be checked if the queue is already large
while update_q.qsize() >= 2000:
logger.warning(f"Recheck watches queue size limit reached ({MAX_QUEUE_SIZE}), skipping adding more items")
app.config.exit.wait(10.0)
recheck_time_system_seconds = int(datastore.threshold_seconds)
# Check for watches outside of the time threshold to put in the thread queue.
for watch_index, uuid in enumerate(watch_uuid_list):
# Re #438 - Check queue size every 100 watches for CPU efficiency (not every watch)
if watch_index % 100 == 0:
current_queue_size = update_q.qsize()
if current_queue_size >= MAX_QUEUE_SIZE:
logger.debug(f"Queue size limit reached ({current_queue_size}/{MAX_QUEUE_SIZE}), stopping scheduler this iteration.")
break
for uuid in watch_uuid_list:
now = time.time()
watch = datastore.data['watching'].get(uuid)
if not watch:
@@ -1084,7 +1065,7 @@ def ticker_thread_check_time_launch_checks():
seconds_since_last_recheck = now - watch['last_checked']
if seconds_since_last_recheck >= (threshold + watch.jitter_seconds) and seconds_since_last_recheck >= recheck_time_minimum_seconds:
if not uuid in running_uuids and uuid not in queued_uuids:
if not uuid in running_uuids and uuid not in [q_uuid.item['uuid'] for q_uuid in update_q.queue]:
# Proxies can be set to have a limit on seconds between which they can be called
watch_proxy = datastore.get_preferred_proxy_for_watch(uuid=uuid)
@@ -1127,4 +1108,4 @@ def ticker_thread_check_time_launch_checks():
watch.jitter_seconds = 0
# Should be low so we can break this out in testing
app.config.exit.wait(WAIT_TIME_BETWEEN_LOOP)
app.config.exit.wait(1)

View File

@@ -29,9 +29,6 @@ def get_timeago_locale(flask_locale):
"""
locale_map = {
'zh': 'zh_CN', # Chinese Simplified
# timeago library just hasn't been updated to use the more modern locale naming convention, before BCP 47 / RFC 5646.
'zh_TW': 'zh_TW', # Chinese Traditional (timeago uses zh_TW)
'zh_Hant_TW': 'zh_TW', # Flask-Babel normalizes zh_TW to zh_Hant_TW, map back to timeago's zh_TW
'pt': 'pt_PT', # Portuguese (Portugal)
'sv': 'sv_SE', # Swedish
'no': 'nb_NO', # Norwegian Bokmål
@@ -56,7 +53,7 @@ LANGUAGE_DATA = {
'it': {'flag': 'fi fi-it fis', 'name': 'Italiano'},
'ja': {'flag': 'fi fi-jp fis', 'name': '日本語'},
'zh': {'flag': 'fi fi-cn fis', 'name': '中文 (简体)'},
'zh_Hant_TW': {'flag': 'fi fi-tw fis', 'name': '繁體中文'},
'zh_TW': {'flag': 'fi fi-tw fis', 'name': '繁體中文'},
'ru': {'flag': 'fi fi-ru fis', 'name': 'Русский'},
'pl': {'flag': 'fi fi-pl fis', 'name': 'Polski'},
'nl': {'flag': 'fi fi-nl fis', 'name': 'Nederlands'},

View File

@@ -10,40 +10,25 @@ from pathlib import Path
from loguru import logger
from .. import jinja2_custom as safe_jinja
from ..diff import ADDED_PLACEMARKER_OPEN
from ..html_tools import TRANSLATE_WHITESPACE_TABLE
FAVICON_RESAVE_THRESHOLD_SECONDS=86400
BROTLI_COMPRESS_SIZE_THRESHOLD = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024))
minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3))
mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7}
def _brotli_save(contents, filepath, mode=None, fallback_uncompressed=False):
def _brotli_compress_worker(conn, filepath, mode=None):
"""
Save compressed data using native brotli.
Testing shows no memory leak when using gc.collect() after compression.
Worker function to compress data with brotli in a separate process.
This isolates memory - when process exits, OS reclaims all memory.
Args:
contents: data to compress (str or bytes)
conn: multiprocessing.Pipe connection to receive data
filepath: destination file path
mode: brotli compression mode (e.g., brotli.MODE_TEXT)
fallback_uncompressed: if True, save uncompressed on failure; if False, raise exception
Returns:
str: actual filepath saved (may differ from input if fallback used)
Raises:
Exception: if compression fails and fallback_uncompressed is False
"""
import brotli
import gc
# Ensure contents are bytes
if isinstance(contents, str):
contents = contents.encode('utf-8')
try:
logger.debug(f"Starting brotli compression of {len(contents)} bytes.")
# Receive data from parent process via pipe (avoids pickle overhead)
contents = conn.recv()
if mode is not None:
compressed_data = brotli.compress(contents, mode=mode)
@@ -53,26 +38,117 @@ def _brotli_save(contents, filepath, mode=None, fallback_uncompressed=False):
with open(filepath, 'wb') as f:
f.write(compressed_data)
logger.debug(f"Finished brotli compression - From {len(contents)} to {len(compressed_data)} bytes.")
# Send success status back
conn.send(True)
# No need for explicit cleanup - process exit frees all memory
except Exception as e:
logger.error(f"Brotli compression worker failed: {e}")
conn.send(False)
finally:
conn.close()
# Force garbage collection to prevent memory buildup
gc.collect()
return filepath
def _brotli_subprocess_save(contents, filepath, mode=None, timeout=30, fallback_uncompressed=False):
"""
Save compressed data using subprocess to isolate memory.
Uses Pipe to avoid pickle overhead for large data.
Args:
contents: data to compress (str or bytes)
filepath: destination file path
mode: brotli compression mode (e.g., brotli.MODE_TEXT)
timeout: subprocess timeout in seconds
fallback_uncompressed: if True, save uncompressed on failure; if False, raise exception
Returns:
str: actual filepath saved (may differ from input if fallback used)
Raises:
Exception: if compression fails and fallback_uncompressed is False
"""
import brotli
import multiprocessing
import sys
# Ensure contents are bytes
if isinstance(contents, str):
contents = contents.encode('utf-8')
# Use explicit spawn context for thread safety (avoids fork() with multi-threaded parent)
# Always use spawn - consistent behavior in tests and production
ctx = multiprocessing.get_context('spawn')
parent_conn, child_conn = ctx.Pipe()
# Run compression in subprocess using spawn (not fork)
proc = ctx.Process(target=_brotli_compress_worker, args=(child_conn, filepath, mode))
# Windows-safe: Set daemon=False explicitly to avoid issues with process cleanup
proc.daemon = False
proc.start()
try:
# Send data to subprocess via pipe (avoids pickle)
parent_conn.send(contents)
# Wait for result with timeout
if parent_conn.poll(timeout):
success = parent_conn.recv()
else:
success = False
logger.warning(f"Brotli compression subprocess timed out after {timeout}s")
# Graceful termination with platform-aware cleanup
try:
proc.terminate()
except Exception as term_error:
logger.debug(f"Process termination issue (may be normal on Windows): {term_error}")
parent_conn.close()
proc.join(timeout=5)
# Force kill if still alive after graceful termination
if proc.is_alive():
try:
if sys.platform == 'win32':
# Windows: use kill() which is more forceful
proc.kill()
else:
# Unix: terminate() already sent SIGTERM, now try SIGKILL
proc.kill()
proc.join(timeout=2)
except Exception as kill_error:
logger.warning(f"Failed to kill brotli compression process: {kill_error}")
# Check if file was created successfully
if success and os.path.exists(filepath):
return filepath
except Exception as e:
logger.error(f"Brotli compression error: {e}")
try:
parent_conn.close()
except:
pass
try:
proc.terminate()
proc.join(timeout=2)
except:
pass
# Compression failed
if fallback_uncompressed:
logger.warning(f"Brotli compression failed for {filepath}, saving uncompressed")
fallback_path = filepath.replace('.br', '')
with open(fallback_path, 'wb') as f:
f.write(contents)
return fallback_path
else:
raise Exception(f"Brotli compression failed for {filepath}: {e}")
# Compression failed
if fallback_uncompressed:
logger.warning(f"Brotli compression failed for {filepath}, saving uncompressed")
fallback_path = filepath.replace('.br', '')
with open(fallback_path, 'wb') as f:
f.write(contents)
return fallback_path
else:
raise Exception(f"Brotli compression subprocess failed for {filepath}")
FAVICON_RESAVE_THRESHOLD_SECONDS=86400
minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3))
mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7}
class model(watch_base):
__newest_history_key = None
@@ -416,6 +492,7 @@ class model(watch_base):
self.ensure_data_dir_exists()
threshold = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024))
skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False'))
# Binary data - detect file type and save without compression
@@ -439,7 +516,7 @@ class model(watch_base):
# Text data - use brotli compression if enabled and above threshold
else:
if not skip_brotli and len(contents) > BROTLI_COMPRESS_SIZE_THRESHOLD:
if not skip_brotli and len(contents) > threshold:
# Compressed text
import brotli
snapshot_fname = f"{snapshot_id}.txt.br"
@@ -447,7 +524,7 @@ class model(watch_base):
if not os.path.exists(dest):
try:
actual_dest = _brotli_save(contents, dest, mode=brotli.MODE_TEXT, fallback_uncompressed=True)
actual_dest = _brotli_subprocess_save(contents, dest, mode=brotli.MODE_TEXT, fallback_uncompressed=True)
if actual_dest != dest:
snapshot_fname = os.path.basename(actual_dest)
except Exception as e:
@@ -873,13 +950,13 @@ class model(watch_base):
def save_last_text_fetched_before_filters(self, contents):
import brotli
filepath = os.path.join(self.watch_data_dir, 'last-fetched.br')
_brotli_save(contents, filepath, mode=brotli.MODE_TEXT, fallback_uncompressed=False)
_brotli_subprocess_save(contents, filepath, mode=brotli.MODE_TEXT, fallback_uncompressed=False)
def save_last_fetched_html(self, timestamp, contents):
self.ensure_data_dir_exists()
snapshot_fname = f"{timestamp}.html.br"
filepath = os.path.join(self.watch_data_dir, snapshot_fname)
_brotli_save(contents, filepath, mode=None, fallback_uncompressed=True)
_brotli_subprocess_save(contents, filepath, mode=None, fallback_uncompressed=True)
self._prune_last_fetched_html_snapshots()
def get_fetched_html(self, timestamp):

View File

@@ -13,9 +13,14 @@ Research: https://github.com/libvips/pyvips/issues/234
import multiprocessing
# CRITICAL: Use 'spawn' context instead of 'fork' to avoid inheriting parent's
# CRITICAL: Use 'spawn' instead of 'fork' to avoid inheriting parent's
# LibVIPS threading state which can cause hangs in gaussblur operations
# https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods
try:
multiprocessing.set_start_method('spawn', force=False)
except RuntimeError:
# Already set, ignore
pass
def _worker_generate_diff(conn, img_bytes_from, img_bytes_to, threshold, blur_sigma, max_width, max_height):
@@ -90,10 +95,9 @@ def generate_diff_isolated(img_bytes_from, img_bytes_to, threshold, blur_sigma,
Returns:
bytes: JPEG diff image or None on failure
"""
ctx = multiprocessing.get_context('spawn')
parent_conn, child_conn = ctx.Pipe()
parent_conn, child_conn = multiprocessing.Pipe()
p = ctx.Process(
p = multiprocessing.Process(
target=_worker_generate_diff,
args=(child_conn, img_bytes_from, img_bytes_to, threshold, blur_sigma, max_width, max_height)
)
@@ -136,8 +140,7 @@ def calculate_change_percentage_isolated(img_bytes_from, img_bytes_to, threshold
Returns:
float: Change percentage
"""
ctx = multiprocessing.get_context('spawn')
parent_conn, child_conn = ctx.Pipe()
parent_conn, child_conn = multiprocessing.Pipe()
def _worker_calculate(conn):
try:
@@ -182,7 +185,7 @@ def calculate_change_percentage_isolated(img_bytes_from, img_bytes_to, threshold
finally:
conn.close()
p = ctx.Process(target=_worker_calculate, args=(child_conn,))
p = multiprocessing.Process(target=_worker_calculate, args=(child_conn,))
p.start()
result = 0.0
@@ -230,8 +233,7 @@ def compare_images_isolated(img_bytes_from, img_bytes_to, threshold, blur_sigma,
tuple: (changed_detected, change_percentage)
"""
print(f"[Parent] Starting compare_images_isolated subprocess", flush=True)
ctx = multiprocessing.get_context('spawn')
parent_conn, child_conn = ctx.Pipe()
parent_conn, child_conn = multiprocessing.Pipe()
def _worker_compare(conn):
try:
@@ -299,7 +301,7 @@ def compare_images_isolated(img_bytes_from, img_bytes_to, threshold, blur_sigma,
finally:
conn.close()
p = ctx.Process(target=_worker_compare, args=(child_conn,))
p = multiprocessing.Process(target=_worker_compare, args=(child_conn,))
print(f"[Parent] Starting subprocess (pid will be assigned)", flush=True)
p.start()
print(f"[Parent] Subprocess started (pid={p.pid}), waiting for result (30s timeout)", flush=True)

View File

@@ -86,7 +86,6 @@ class RecheckPriorityQueue:
def get(self, block: bool = True, timeout: Optional[float] = None):
"""Thread-safe sync get with priority ordering"""
import queue
try:
# Wait for notification
self.sync_q.get(block=block, timeout=timeout)
@@ -104,11 +103,8 @@ class RecheckPriorityQueue:
logger.debug(f"Successfully retrieved item: {self._get_item_uuid(item)}")
return item
except queue.Empty:
# Queue is empty with timeout - expected behavior, re-raise without logging
raise
except Exception as e:
# Re-raise without logging - caller (worker) will handle and log appropriately
logger.critical(f"CRITICAL: Failed to get item from queue: {str(e)}")
raise
# ASYNC INTERFACE (for workers)
@@ -176,16 +172,7 @@ class RecheckPriorityQueue:
def empty(self) -> bool:
"""Check if queue is empty"""
return self.qsize() == 0
def get_queued_uuids(self) -> list:
"""Get list of all queued UUIDs efficiently with single lock"""
try:
with self._lock:
return [item.item['uuid'] for item in self._priority_items if hasattr(item, 'item') and 'uuid' in item.item]
except Exception as e:
logger.critical(f"CRITICAL: Failed to get queued UUIDs: {str(e)}")
return []
def close(self):
"""Close the janus queue"""
try:

View File

@@ -150,8 +150,11 @@ def handle_watch_update(socketio, **kwargs):
# Get list of watches that are currently running
running_uuids = worker_handler.get_running_uuids()
# Get list of watches in the queue (efficient single-lock method)
queue_list = update_q.get_queued_uuids()
# 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'])
# Get the error texts from the watch
error_texts = watch.compile_error_texts()

View File

@@ -82,22 +82,27 @@ echo "RUNNING WITH BASE_URL SET"
# Re #65 - Ability to include a link back to the installation, in the notification.
export BASE_URL="https://really-unique-domain.io"
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 tests/test_notification.py
# Re-run with HIDE_REFERER set - could affect login
export HIDE_REFERER=True
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 tests/test_notification.py tests/test_access_control.py
pytest -vv -s --maxfail=1 tests/test_access_control.py
# Re-run a few tests that will trigger brotli based storage
# And again with brotli+screenshot attachment
SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=5 REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 --dist=load tests/test_backend.py tests/test_rss.py tests/test_unique_lines.py tests/test_notification.py tests/test_access_control.py
export SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=5
pytest -vv -s --maxfail=1 tests/test_access_control.py
REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest tests/test_notification.py
pytest -vv -s --maxfail=1 tests/test_backend.py
pytest -vv -s --maxfail=1 tests/test_rss.py
pytest -vv -s --maxfail=1 tests/test_unique_lines.py
# Try high concurrency
FETCH_WORKERS=50 pytest tests/test_history_consistency.py -vv -l -s
FETCH_WORKERS=130 pytest tests/test_history_consistency.py -v -l
# Check file:// will pickup a file when enabled
echo "Hello world" > /tmp/test-file.txt
ALLOW_FILE_URI=yes pytest -vv -s tests/test_security.py
# Run it again so that brotli kicks in
TEST_WITH_BROTLI=1 SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=100 FETCH_WORKERS=20 pytest tests/test_history_consistency.py -vv -l -s

View File

@@ -76,7 +76,7 @@ $(document).ready(function () {
// Cache DOM elements for performance
const queueBubble = document.getElementById('queue-bubble');
const queueSizePagerInfoText = document.getElementById('queue-size-int');
// 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) {
@@ -118,10 +118,6 @@ $(document).ready(function () {
socket.on('queue_size', function (data) {
console.log(`${data.event_timestamp} - Queue size update: ${data.q_length}`);
if(queueSizePagerInfoText) {
queueSizePagerInfoText.textContent = parseInt(data.q_length).toLocaleString() || 'None';
}
document.body.classList.toggle('has-queue', parseInt(data.q_length) > 0);
// Update queue bubble in action sidebar
//if (queueBubble) {

View File

@@ -1 +1 @@
.comparison-score{padding:1em;background:var(--color-table-stripe);border-radius:4px;margin:1em 0;border:1px solid var(--color-border-table-cell);color:var(--color-text)}.change-detected{color:#d32f2f;font-weight:bold}.no-change{color:#388e3c;font-weight:bold}.comparison-grid{display:grid;grid-template-columns:1fr 1fr;gap:1em;margin:1em 1em}@media(max-width: 1200px){.comparison-grid{grid-template-columns:1fr}}.image-comparison{position:relative;width:100%;overflow:hidden;border:1px solid var(--color-border-table-cell);box-shadow:0 2px 4px rgba(0, 0, 0, 0.1);user-select:none}.image-comparison img{display:block;width:100%;height:auto;max-width:100%;border:none;box-shadow:none}.comparison-image-wrapper{position:relative;width:100%;display:flex;align-items:flex-start;justify-content:center;background-color:var(--color-background);background-image:linear-gradient(45deg, var(--color-table-stripe) 25%, transparent 25%),linear-gradient(-45deg, var(--color-table-stripe) 25%, transparent 25%),linear-gradient(45deg, transparent 75%, var(--color-table-stripe) 75%),linear-gradient(-45deg, transparent 75%, var(--color-table-stripe) 75%);background-size:20px 20px;background-position:0 0,0 10px,10px -10px,-10px 0px}.comparison-after{position:absolute;top:0;left:0;width:100%;height:100%;clip-path:inset(0 0 0 50%)}.comparison-slider{position:absolute;top:0;left:50%;width:4px;height:100%;background:#0078e7;cursor:ew-resize;transform:translateX(-2px);z-index:10}.comparison-handle{position:absolute;top:50%;left:50%;width:48px;height:48px;background:#0078e7;border:3px solid #fff;border-radius:50%;transform:translate(-50%, -50%);box-shadow:0 2px 8px rgba(0, 0, 0, 0.3);display:flex;align-items:center;justify-content:center;cursor:ew-resize;transition:top .1s ease-out}.comparison-handle::after{content:"⇄";color:#fff;font-size:24px;font-weight:bold;pointer-events:none}.comparison-labels{position:absolute;top:10px;width:100%;display:flex;justify-content:space-between;padding:0 0px;z-index:5;pointer-events:none}.comparison-label{background:rgba(0, 0, 0, 0.7);color:#fff;padding:.5em 1em;border-radius:4px;font-size:.9em;font-weight:bold}.screenshot-panel{text-align:center;background:var(--color-background);border:1px solid var(--color-border-table-cell);border-radius:4px;padding:1em;box-shadow:0 2px 4px rgba(0, 0, 0, 0.05)}.screenshot-panel h3{margin:0 0 1em 0;font-size:1.1em;color:var(--color-text);border-bottom:2px solid var(--color-background-button-primary);padding-bottom:.5em}.screenshot-panel.diff h3{border-bottom-color:#d32f2f}.screenshot-panel img{max-width:100%;height:auto;border:1px solid var(--color-border-table-cell);box-shadow:0 2px 4px rgba(0, 0, 0, 0.1)}.version-selector{display:inline-block;margin:0 .5em}.version-selector label{font-weight:bold;margin-right:.5em;color:var(--color-text)}#settings{background:var(--color-background);padding:1.5em;border-radius:4px;box-shadow:0 2px 4px rgba(0, 0, 0, 0.05);margin-bottom:2em;border:1px solid var(--color-border-table-cell)}#settings h2{margin-top:0;color:var(--color-text)}.diff-fieldset{border:none;padding:0;margin:0}.edit-link{float:right;margin-top:-0.5em}.comparison-description{color:var(--color-text-input-description);font-size:.9em;margin-bottom:1em}.download-link{color:var(--color-link);text-decoration:none;display:inline-flex;align-items:center;gap:.3em;font-size:.85em}.download-link:hover{text-decoration:underline}.diff-section-header{color:#d32f2f;font-size:.9em;margin-bottom:1em;font-weight:bold;display:flex;align-items:center;justify-content:center;gap:1em}.comparison-history-section{margin-top:3em;padding:1em;background:var(--color-background);border:1px solid var(--color-border-table-cell);border-radius:4px;box-shadow:0 2px 4px rgba(0, 0, 0, 0.05)}.comparison-history-section h3{color:var(--color-text)}.comparison-history-section p{color:var(--color-text-input-description);font-size:.9em}.history-changed-yes{color:#d32f2f;font-weight:bold}.history-changed-no{color:#388e3c}
.comparison-score{padding:1em;background:var(--color-table-stripe);border-radius:4px;margin:1em 0;border:1px solid var(--color-border-table-cell);color:var(--color-text)}.change-detected{color:#d32f2f;font-weight:bold}.no-change{color:#388e3c;font-weight:bold}.comparison-grid{display:grid;grid-template-columns:1fr 1fr;gap:1em;margin:1em 1em}@media(max-width: 1200px){.comparison-grid{grid-template-columns:1fr}}.image-comparison{position:relative;width:100%;overflow:hidden;border:1px solid var(--color-border-table-cell);box-shadow:0 2px 4px rgba(0,0,0,.1);user-select:none}.image-comparison img{display:block;width:100%;height:auto;max-width:100%;border:none;box-shadow:none}.comparison-image-wrapper{position:relative;width:100%;display:flex;align-items:flex-start;justify-content:center;background-color:var(--color-background);background-image:linear-gradient(45deg, var(--color-table-stripe) 25%, transparent 25%),linear-gradient(-45deg, var(--color-table-stripe) 25%, transparent 25%),linear-gradient(45deg, transparent 75%, var(--color-table-stripe) 75%),linear-gradient(-45deg, transparent 75%, var(--color-table-stripe) 75%);background-size:20px 20px;background-position:0 0,0 10px,10px -10px,-10px 0px}.comparison-after{position:absolute;top:0;left:0;width:100%;height:100%;clip-path:inset(0 0 0 50%)}.comparison-slider{position:absolute;top:0;left:50%;width:4px;height:100%;background:#0078e7;cursor:ew-resize;transform:translateX(-2px);z-index:10}.comparison-handle{position:absolute;top:50%;left:50%;width:48px;height:48px;background:#0078e7;border:3px solid #fff;border-radius:50%;transform:translate(-50%, -50%);box-shadow:0 2px 8px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;cursor:ew-resize;transition:top .1s ease-out}.comparison-handle::after{content:"⇄";color:#fff;font-size:24px;font-weight:bold;pointer-events:none}.comparison-labels{position:absolute;top:10px;width:100%;display:flex;justify-content:space-between;padding:0 0px;z-index:5;pointer-events:none}.comparison-label{background:rgba(0,0,0,.7);color:#fff;padding:.5em 1em;border-radius:4px;font-size:.9em;font-weight:bold}.screenshot-panel{text-align:center;background:var(--color-background);border:1px solid var(--color-border-table-cell);border-radius:4px;padding:1em;box-shadow:0 2px 4px rgba(0,0,0,.05)}.screenshot-panel h3{margin:0 0 1em 0;font-size:1.1em;color:var(--color-text);border-bottom:2px solid var(--color-background-button-primary);padding-bottom:.5em}.screenshot-panel.diff h3{border-bottom-color:#d32f2f}.screenshot-panel img{max-width:100%;height:auto;border:1px solid var(--color-border-table-cell);box-shadow:0 2px 4px rgba(0,0,0,.1)}.version-selector{display:inline-block;margin:0 .5em}.version-selector label{font-weight:bold;margin-right:.5em;color:var(--color-text)}#settings{background:var(--color-background);padding:1.5em;border-radius:4px;box-shadow:0 2px 4px rgba(0,0,0,.05);margin-bottom:2em;border:1px solid var(--color-border-table-cell)}#settings h2{margin-top:0;color:var(--color-text)}.diff-fieldset{border:none;padding:0;margin:0}.edit-link{float:right;margin-top:-0.5em}.comparison-description{color:var(--color-text-input-description);font-size:.9em;margin-bottom:1em}.download-link{color:var(--color-link);text-decoration:none;display:inline-flex;align-items:center;gap:.3em;font-size:.85em}.download-link:hover{text-decoration:underline}.diff-section-header{color:#d32f2f;font-size:.9em;margin-bottom:1em;font-weight:bold;display:flex;align-items:center;justify-content:center;gap:1em}.comparison-history-section{margin-top:3em;padding:1em;background:var(--color-background);border:1px solid var(--color-border-table-cell);border-radius:4px;box-shadow:0 2px 4px rgba(0,0,0,.05)}.comparison-history-section h3{color:var(--color-text)}.comparison-history-section p{color:var(--color-text-input-description);font-size:.9em}.history-changed-yes{color:#d32f2f;font-weight:bold}.history-changed-no{color:#388e3c}

View File

@@ -1 +1 @@
#diff-form{background:rgba(0, 0, 0, 0.05);padding:1em;border-radius:10px;margin-bottom:1em;color:#fff;font-size:.9rem;text-align:center}#diff-form label.from-to-label{width:4rem;text-decoration:none;padding:.5rem}#diff-form label.from-to-label#change-from{color:#b30000;background:#fadad7}#diff-form label.from-to-label#change-to{background:#eaf2c2;color:#406619}#diff-form #diff-style>span{display:inline-block;padding:.3em}#diff-form #diff-style>span label{font-weight:normal}#diff-form *{vertical-align:middle}body.difference-page section.content{padding-top:40px}#diff-ui{background:var(--color-background);padding:1rem;border-radius:5px}@media(min-width: 767px){#diff-ui{min-width:50%}}#diff-ui #text{font-size:11px}#diff-ui pre{white-space:break-spaces}#diff-ui h1{display:inline;font-size:100%}#diff-ui #result{white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word}#diff-ui .source{position:absolute;right:1%;top:.2em}@-moz-document url-prefix(){#diff-ui body{height:99%}}#diff-ui td#diff-col div{text-align:justify;white-space:pre-wrap}#diff-ui .ignored{background-color:#ccc;opacity:.7}#diff-ui .triggered{background-color:#1b98f8}#diff-ui .ignored.triggered{background-color:red}#diff-ui .tab-pane-inner#screenshot{text-align:center}#diff-ui .tab-pane-inner#screenshot img{max-width:99%}#diff-ui .pure-form button.reset-margin{margin:0px}#diff-ui .diff-fieldset{display:flex;align-items:center;gap:4px;flex-wrap:wrap}#diff-ui ul#highlightSnippetActions{list-style-type:none;display:flex;align-items:center;justify-content:center;gap:1.5rem;flex-wrap:wrap;padding:0;margin:0}#diff-ui ul#highlightSnippetActions li{display:flex;flex-direction:column;align-items:center;text-align:center;padding:.5rem;gap:.3rem}#diff-ui ul#highlightSnippetActions li button,#diff-ui ul#highlightSnippetActions li a{white-space:nowrap}#diff-ui ul#highlightSnippetActions span{font-size:.8rem;color:var(--color-text-input-description)}#diff-ui #cell-diff-jump-visualiser{display:flex;flex-direction:row;gap:1px;background:var(--color-background);border-radius:3px;overflow-x:hidden;position:sticky;top:0;z-index:10;padding-top:1rem;padding-bottom:1rem;justify-content:center}#diff-ui #cell-diff-jump-visualiser>div{flex:1;min-width:1px;max-width:10px;height:10px;background:var(--color-background-button-cancel);opacity:.3;border-radius:1px;transition:opacity .2s;position:relative}#diff-ui #cell-diff-jump-visualiser>div.deletion{background:#b30000;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.insertion{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.note{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.mixed{background:linear-gradient(to right, #b30000 50%, #406619 50%);opacity:1}#diff-ui #cell-diff-jump-visualiser>div.current-position::after{content:"";position:absolute;bottom:-6px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:4px solid rgba(0, 0, 0, 0);border-right:4px solid rgba(0, 0, 0, 0);border-bottom:4px solid var(--color-text)}#diff-ui #cell-diff-jump-visualiser>div:hover{opacity:.8;cursor:pointer}#text-diff-heading-area .snapshot-age{padding:4px;margin:.5rem 0;background-color:var(--color-background-snapshot-age);border-radius:3px;font-weight:bold;margin-bottom:4px}#text-diff-heading-area .snapshot-age.error{background-color:var(--color-error-background-snapshot-age);color:var(--color-error-text-snapshot-age)}#text-diff-heading-area .snapshot-age>*{padding-right:1rem}
#diff-form{background:rgba(0,0,0,.05);padding:1em;border-radius:10px;margin-bottom:1em;color:#fff;font-size:.9rem;text-align:center}#diff-form label.from-to-label{width:4rem;text-decoration:none;padding:.5rem}#diff-form label.from-to-label#change-from{color:#b30000;background:#fadad7}#diff-form label.from-to-label#change-to{background:#eaf2c2;color:#406619}#diff-form #diff-style>span{display:inline-block;padding:.3em}#diff-form #diff-style>span label{font-weight:normal}#diff-form *{vertical-align:middle}body.difference-page section.content{padding-top:40px}#diff-ui{background:var(--color-background);padding:1rem;border-radius:5px}@media(min-width: 767px){#diff-ui{min-width:50%}}#diff-ui #text{font-size:11px}#diff-ui pre{white-space:break-spaces}#diff-ui h1{display:inline;font-size:100%}#diff-ui #result{white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word}#diff-ui .source{position:absolute;right:1%;top:.2em}@-moz-document url-prefix(){#diff-ui body{height:99%}}#diff-ui td#diff-col div{text-align:justify;white-space:pre-wrap}#diff-ui .ignored{background-color:#ccc;opacity:.7}#diff-ui .triggered{background-color:#1b98f8}#diff-ui .ignored.triggered{background-color:red}#diff-ui .tab-pane-inner#screenshot{text-align:center}#diff-ui .tab-pane-inner#screenshot img{max-width:99%}#diff-ui .pure-form button.reset-margin{margin:0px}#diff-ui .diff-fieldset{display:flex;align-items:center;gap:4px;flex-wrap:wrap}#diff-ui ul#highlightSnippetActions{list-style-type:none;display:flex;align-items:center;justify-content:center;gap:1.5rem;flex-wrap:wrap;padding:0;margin:0}#diff-ui ul#highlightSnippetActions li{display:flex;flex-direction:column;align-items:center;text-align:center;padding:.5rem;gap:.3rem}#diff-ui ul#highlightSnippetActions li button,#diff-ui ul#highlightSnippetActions li a{white-space:nowrap}#diff-ui ul#highlightSnippetActions span{font-size:.8rem;color:var(--color-text-input-description)}#diff-ui #cell-diff-jump-visualiser{display:flex;flex-direction:row;gap:1px;background:var(--color-background);border-radius:3px;overflow-x:hidden;position:sticky;top:0;z-index:10;padding-top:1rem;padding-bottom:1rem;justify-content:center}#diff-ui #cell-diff-jump-visualiser>div{flex:1;min-width:1px;max-width:10px;height:10px;background:var(--color-background-button-cancel);opacity:.3;border-radius:1px;transition:opacity .2s;position:relative}#diff-ui #cell-diff-jump-visualiser>div.deletion{background:#b30000;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.insertion{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.note{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.mixed{background:linear-gradient(to right, #b30000 50%, #406619 50%);opacity:1}#diff-ui #cell-diff-jump-visualiser>div.current-position::after{content:"";position:absolute;bottom:-6px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:4px solid rgba(0,0,0,0);border-right:4px solid rgba(0,0,0,0);border-bottom:4px solid var(--color-text)}#diff-ui #cell-diff-jump-visualiser>div:hover{opacity:.8;cursor:pointer}#text-diff-heading-area .snapshot-age{padding:4px;margin:.5rem 0;background-color:var(--color-background-snapshot-age);border-radius:3px;font-weight:bold;margin-bottom:4px}#text-diff-heading-area .snapshot-age.error{background-color:var(--color-error-background-snapshot-age);color:var(--color-error-text-snapshot-age)}#text-diff-heading-area .snapshot-age>*{padding-right:1rem}

View File

@@ -1,4 +1,6 @@
.pagination-page-info {
color: #fff;
font-size: 0.85rem;
text-transform: capitalize;
}

View File

@@ -125,11 +125,6 @@ $grid-gap: 0.5rem;
border-bottom: none;
}
// Empty state message - span full width on mobile
> td[colspan] {
grid-column: 1 / -1;
}
> td.title-col {
grid-column: 1 / -1;
grid-row: 1;

View File

@@ -1,32 +1,4 @@
/* table related */
#stats_row {
display: flex;
align-items: center;
width: 100%;
color: #fff;
font-size: 0.85rem;
>* {
padding-bottom: 0.5rem;
}
.left {
text-align: left;
}
.right {
opacity: 0.5;
transition: opacity 0.6s ease;
margin-left: auto; /* pushes it to the far right */
text-align: right;
}
}
body.has-queue {
#stats_row {
.right {
opacity: 1.0;
}
}
}
.watch-table {
width: 100%;
font-size: 80%;

View File

@@ -33,31 +33,6 @@
@use "parts/login_form";
@use "parts/tabs";
// Smooth transitions for theme switching
body,
.pure-table,
.pure-table thead,
.pure-table td,
.pure-table th,
.pure-form input,
.pure-form textarea,
.pure-form select,
.edit-form .inner,
.pure-menu-horizontal,
footer,
.sticky-tab,
#diff-jump,
.button-tag,
#new-watch-form,
#new-watch-form input:not(.pure-button),
code,
.messages li,
#checkbox-operations,
.inline-warning,
a,
.watch-controls img {
transition: color 0.4s ease, background-color 0.4s ease, background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease;
}
body {
color: var(--color-text);

File diff suppressed because one or more lines are too long

View File

@@ -186,7 +186,7 @@ class ChangeDetectionStore:
# Finally start the thread that will manage periodic data saves to JSON
# Only start if thread is not already running (reload_state might be called multiple times)
if not self.save_data_thread or not self.save_data_thread.is_alive():
self.save_data_thread = threading.Thread(target=self.save_datastore, daemon=True, name="DatastoreSaver")
self.save_data_thread = threading.Thread(target=self.save_datastore)
self.save_data_thread.start()
def rehydrate_entity(self, uuid, entity, processor_override=None):

View File

@@ -17,8 +17,6 @@ _MAP = {
def strtobool(value):
if not value:
return False
try:
return _MAP[str(value).lower()]
except KeyError:

View File

@@ -8,15 +8,16 @@
<span class="pure-form-message-inline">
Body for all notifications &dash; You can use <a target="newwindow" href="https://jinja.palletsprojects.com/en/3.0.x/templates/">Jinja2</a> templating in the notification title, body and URL, and tokens from below.
</span><br>
<div data-target="#notification-tokens-info{{ suffix }}" class="toggle-show pure-button button-tag button-xsmall">{{ _('Show token/placeholders') }}
<div data-target="#notification-tokens-info{{ suffix }}" class="toggle-show pure-button button-tag button-xsmall">Show
token/placeholders
</div>
</div>
<div class="pure-controls" style="display: none;" id="notification-tokens-info{{ suffix }}">
<table class="pure-table" id="token-table">
<thead>
<tr>
<th>{{ _('Token') }}</th>
<th>{{ _('Description') }}</th>
<th>Token</th>
<th>Description</th>
</tr>
</thead>
<tbody>
@@ -125,7 +126,7 @@
<p>
<strong>Tip:</strong> Use <a target="newwindow" href="https://github.com/caronc/apprise">AppRise Notification URLs</a> for notification to just about any service! <i><a target="newwindow" href="https://github.com/dgtlmoon/changedetection.io/wiki/Notification-configuration-notes">Please read the notification services wiki here for important configuration notes</a></i>.<br>
</p>
<div data-target="#advanced-help-notifications" class="toggle-show pure-button button-tag button-xsmall">{{ _('Show advanced help and tips') }}</div>
<div data-target="#advanced-help-notifications" class="toggle-show pure-button button-tag button-xsmall">Show advanced help and tips</div>
<ul style="display: none" id="advanced-help-notifications">
<li><code><a target="newwindow" href="https://github.com/caronc/apprise/wiki/Notify_discord">discord://</a></code> (or <code>https://discord.com/api/webhooks...</code>)) only supports a maximum <strong>2,000 characters</strong> of notification text, including the title.</li>
<li><code><a target="newwindow" href="https://github.com/caronc/apprise/wiki/Notify_telegram">tgram://</a></code> bots can't send messages to other bots, so you should specify chat ID of non-bot user.</li>
@@ -135,20 +136,20 @@
</ul>
</div>
<div class="notifications-wrapper">
<a id="send-test-notification" class="pure-button button-secondary button-xsmall" >{{ _('Send test notification') }}</a> <div class="spinner" style="display: none;"></div>
<a id="send-test-notification" class="pure-button button-secondary button-xsmall" >Send test notification</a> <div class="spinner" style="display: none;"></div>
{% if emailprefix %}
<a id="add-email-helper" class="pure-button button-secondary button-xsmall" >{{ _('Add email') }} <img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='email.svg')}}" alt="{{ _('Add an email address') }}"> </a>
<a id="add-email-helper" class="pure-button button-secondary button-xsmall" >Add email <img style="height: 1em; display: inline-block" src="{{url_for('static_content', group='images', filename='email.svg')}}" alt="Add an email address"> </a>
{% endif %}
<a href="{{url_for('settings.notification_logs')}}" class="pure-button button-secondary button-xsmall" >{{ _('Notification debug logs') }}</a>
<a href="{{url_for('settings.notification_logs')}}" class="pure-button button-secondary button-xsmall" >Notification debug logs</a>
<br>
<div id="notification-test-log" style="display: none;"><span class="pure-form-message-inline">{{ _('Processing..') }}</span></div>
<div id="notification-test-log" style="display: none;"><span class="pure-form-message-inline">Processing..</span></div>
</div>
</div>
<div class="pure-control-group grey-form-border">
<div class="pure-control-group">
{{ render_field(form.notification_title, class="m-d notification-title", placeholder=settings_application['notification_title']) }}
<span class="pure-form-message-inline">{{ _('Title for all notifications') }}</span>
<span class="pure-form-message-inline">Title for all notifications</span>
</div>
<div class="pure-control-group">
{{ render_field(form.notification_body , rows=5, class="notification-body", placeholder=settings_application['notification_body']) }}
@@ -173,7 +174,7 @@
</div>
<div class="">
{{ render_field(form.notification_format , class="notification-format") }}
<span class="pure-form-message-inline">{{ _('Format for all notifications') }}</span>
<span class="pure-form-message-inline">Format for all notifications</span>
</div>
</div>
{% endmacro %}

View File

@@ -267,7 +267,7 @@
</ul>
<br>
<span class="pure-form-message-inline">
<a href="https://changedetection.io/tutorial/checking-web-pages-changes-according-schedule">{{ _('More help and examples about using the scheduler') }}</a>
<a href="https://changedetection.io/tutorials">{{ _('More help and examples about using the scheduler') }}</a>
</span>
</div>
{% else %}

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{ get_locale()|replace('_', '-') }}" data-darkmode="{{ get_darkmode_state() }}">
<html lang="{{ get_locale() }}" data-darkmode="{{ get_darkmode_state() }}">
<head>
<meta charset="utf-8" >

View File

@@ -6,10 +6,10 @@
") }}
<span class="pure-form-message-inline">
<ul>
<li>{{ _('Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive.') }}</li>
<li>{{ _('Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor') }}</li>
<li>{{ _('Each line is processed separately (think of each line as "OR")') }}</li>
<li>{{ _('Note: Wrap in forward slash / to use regex example:') }} <code>/foo\d/</code></li>
<li>Text to wait for before triggering a change/notification, all text and regex are tested <i>case-insensitive</i>.</li>
<li>Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this watch</li>
<li>Each line is processed separately (think of each line as "OR")</li>
<li>Note: Wrap in forward slash / to use regex example: <code>/foo\d/</code></li>
</ul>
</span>
</div>
@@ -20,10 +20,10 @@
") }}
<span class="pure-form-message-inline">
<ul>
<li>{{ _('Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)') }}</li>
<li>{{ _('Each line processed separately, any line matching will be ignored (removed before creating the checksum)') }}</li>
<li>{{ _('Regular Expression support, wrap the entire line in forward slash') }} <code>/regex/</code></li>
<li>{{ _('Changing this will affect the comparison checksum which may trigger an alert') }}</li>
<li>Matching text will be <strong>ignored</strong> in the text snapshot (you can still see it but it wont trigger a change)</li>
<li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li>
<li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li>
<li>Changing this will affect the comparison checksum which may trigger an alert</li>
</ul>
</span>
<br><br>
@@ -40,10 +40,10 @@ Not in stock
Unavailable") }}
<span class="pure-form-message-inline">
<ul>
<li>{{ _('Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for waiting for when a product is available again') }}</li>
<li>{{ _('Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor') }}</li>
<li>{{ _('All lines here must not exist (think of each line as "OR")') }}</li>
<li>{{ _('Note: Wrap in forward slash / to use regex example:') }} <code>/foo\d/</code></li>
<li>Block change-detection while this text is on the page, all text and regex are tested <i>case-insensitive</i>, good for waiting for when a product is available again</li>
<li>Block text is processed from the result-text that comes out of any CSS/JSON Filters for this watch</li>
<li>All lines here must not exist (think of each line as "OR")</li>
<li>Note: Wrap in forward slash / to use regex example: <code>/foo\d/</code></li>
</ul>
</span>
</div>
@@ -55,17 +55,17 @@ Unavailable") }}
keyword") }}
<span class="pure-form-message-inline">
<ul>
<li>{{ _('Extracts text in the final output (line by line) after other filters using regular expressions or string match:') }}
<li>Extracts text in the final output (line by line) after other filters using regular expressions or string match;
<ul>
<li>{{ _('Regular expression - example') }} <code>/reports.+?2022/i</code></li>
<li>{{ _('Don\'t forget to consider the white-space at the start of a line') }} <code>/.+?reports.+?2022/i</code></li>
<li>{{ _('Use') }} <code>//(?aiLmsux))</code> {{ _('type flags (more') }} <a href="https://docs.python.org/3/library/re.html#index-15">{{ _('information here') }}</a>)<br></li>
<li>{{ _('Keyword example - example') }} <code>Out of stock</code></li>
<li>{{ _('Use groups to extract just that text - example') }} <code>/reports.+?(\d+)/i</code> {{ _('returns a list of years only') }}</li>
<li>{{ _('Example - match lines containing a keyword') }} <code>/.*icecream.*/</code></li>
<li>Regular expression &dash; example <code>/reports.+?2022/i</code></li>
<li>Don't forget to consider the white-space at the start of a line <code>/.+?reports.+?2022/i</code></li>
<li>Use <code>//(?aiLmsux))</code> type flags (more <a href="https://docs.python.org/3/library/re.html#index-15">information here</a>)<br></li>
<li>Keyword example &dash; example <code>Out of stock</code></li>
<li>Use groups to extract just that text &dash; example <code>/reports.+?(\d+)/i</code> returns a list of years only</li>
<li>Example - match lines containing a keyword <code>/.*icecream.*/</code></li>
</ul>
</li>
<li>{{ _('One line per regular-expression/string match') }}</li>
<li>One line per regular-expression/string match</li>
</ul>
</span>
</div>

View File

@@ -270,6 +270,3 @@ def app(request, datastore_path):
request.addfinalizer(teardown)
yield app

View File

@@ -206,10 +206,11 @@ def test_regex_error_handling(client, live_server, measure_memory_usage, datasto
# Add our URL to the import page
test_url = url_for('test_endpoint', _external=True)
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
time.sleep(0.2)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
### test regex error handling
res = client.post(
url_for("ui.ui_edit.edit_page", uuid=uuid),
url_for("ui.ui_edit.edit_page", uuid="first"),
data={"extract_text": '/something bad\d{3/XYZ',
"url": test_url,
"fetch_backend": "html_requests",

View File

@@ -45,7 +45,7 @@ def run_filter_test(client, live_server, content_filter, app_notification_format
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
res = client.get(url_for("watchlist.index"))
assert b'No web page change detection watches configured' not in res.data
assert b'No website watches configured' not in res.data
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)

View File

@@ -4,47 +4,25 @@ import time
import os
import json
from flask import url_for
from loguru import logger
from .. import strtobool
from .util import wait_for_all_checks, delete_all_watches
import brotli
from urllib.parse import urlparse, parse_qs
def test_consistent_history(client, live_server, measure_memory_usage, datastore_path):
# live_server_setup(live_server) # Setup on conftest per function
workers = int(os.getenv("FETCH_WORKERS", 10))
r = range(1, 10+workers)
uuids = set()
sys_fetch_workers = int(os.getenv("FETCH_WORKERS", 10))
workers = range(1, sys_fetch_workers)
now = time.time()
for one in r:
test_url = url_for('test_endpoint', content_type="text/html", content=str(one), _external=True)
res = client.post(
url_for("imports.import_page"),
data={"urls": test_url},
follow_redirects=True
)
for one in workers:
if strtobool(os.getenv("TEST_WITH_BROTLI")):
# A very long string that WILL trigger Brotli compression of the snapshot
# BROTLI_COMPRESS_SIZE_THRESHOLD should be set to say 200
from ..model.Watch import BROTLI_COMPRESS_SIZE_THRESHOLD
content = str(one) + "x" + str(one) * (BROTLI_COMPRESS_SIZE_THRESHOLD + 10)
else:
# Just enough to test datastore
content = str(one)+'x'
test_url = url_for('test_endpoint', content_type="text/html", content=content, _external=True)
uuids.add(client.application.config.get('DATASTORE').add_watch(url=test_url, extras={'title': str(one)}))
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
assert b"1 Imported" in res.data
wait_for_all_checks(client)
duration = time.time() - now
per_worker = duration/sys_fetch_workers
if sys_fetch_workers < 20:
per_worker_threshold=0.6
elif sys_fetch_workers < 50:
per_worker_threshold = 0.8
else:
per_worker_threshold = 1.5
logger.debug(f"All fetched in {duration:.2f}s, {per_worker}s per worker")
# Problematic on github
#assert per_worker < per_worker_threshold, f"If concurrency is working good, no blocking async problems, each worker ({sys_fetch_workers} workers) should have done his job in under {per_worker_threshold}s, got {per_worker:.2f}s per worker, total duration was {duration:.2f}s"
# Essentially just triggers the DB write/update
res = client.post(
@@ -56,7 +34,7 @@ def test_consistent_history(client, live_server, measure_memory_usage, datastore
)
assert b"Settings updated." in res.data
# Wait for the sync DB save to happen
time.sleep(2)
json_db_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, 'url-watches.json')
@@ -66,18 +44,14 @@ def test_consistent_history(client, live_server, measure_memory_usage, datastore
json_obj = json.load(f)
# assert the right amount of watches was found in the JSON
assert len(json_obj['watching']) == len(workers), "Correct number of watches was found in the JSON"
i = 0
assert len(json_obj['watching']) == len(r), "Correct number of watches was found in the JSON"
i=0
# each one should have a history.txt containing just one line
for w in json_obj['watching'].keys():
i += 1
i+=1
history_txt_index_file = os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, 'history.txt')
assert os.path.isfile(history_txt_index_file), f"History.txt should exist where I expect it at {history_txt_index_file}"
# Should be no errors (could be from brotli etc)
assert not live_server.app.config['DATASTORE'].data['watching'][w].get('last_error')
# Same like in model.Watch
with open(history_txt_index_file, "r") as f:
tmp_history = dict(i.strip().split(',', 2) for i in f.readlines())
@@ -89,21 +63,15 @@ def test_consistent_history(client, live_server, measure_memory_usage, datastore
# Find the snapshot one
for fname in files_in_watch_dir:
if fname != 'history.txt' and 'html' not in fname:
if strtobool(os.getenv("TEST_WITH_BROTLI")):
assert fname.endswith('.br'), "Forced TEST_WITH_BROTLI then it should be a .br filename"
full_snapshot_history_path = os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, fname)
# contents should match what we requested as content returned from the test url
if fname.endswith('.br'):
with open(full_snapshot_history_path, 'rb') as f:
contents = brotli.decompress(f.read()).decode('utf-8')
else:
with open(full_snapshot_history_path, 'r') as snapshot_f:
contents = snapshot_f.read()
with open(os.path.join(live_server.app.config['DATASTORE'].datastore_path, w, fname), 'r') as snapshot_f:
contents = snapshot_f.read()
watch_url = json_obj['watching'][w]['url']
u = urlparse(watch_url)
q = parse_qs(u[4])
assert q['content'][0] == contents.strip(), f"Snapshot file {fname} should contain {q['content'][0]}"
watch_title = json_obj['watching'][w]['title']
assert json_obj['watching'][w]['title'], "Watch should have a title set"
assert contents.startswith(watch_title + "x"), f"Snapshot contents in file {fname} should start with '{watch_title}x', got '{contents}'"
assert len(files_in_watch_dir) == 3, "Should be just three files in the dir, html.br snapshot, history.txt and the extracted text snapshot"

View File

@@ -1,71 +1,7 @@
#!/usr/bin/env python3
from flask import url_for
from .util import live_server_setup, wait_for_all_checks
def test_zh_TW(client, live_server, measure_memory_usage, datastore_path):
import time
test_url = url_for('test_endpoint', _external=True)
# Be sure we got a session cookie
res = client.get(url_for("watchlist.index"), follow_redirects=True)
res = client.get(
url_for("set_language", locale="zh_Hant_TW"), # Traditional
follow_redirects=True
)
# HTML follows BCP 47 language tag rules, not underscore-based locale formats.
assert b'<html lang="zh-Hant-TW"' in res.data
assert b'Cannot set language without session cookie' not in res.data
assert '選擇語言'.encode() in res.data
# Check second set works
res = client.get(
url_for("set_language", locale="en_GB"),
follow_redirects=True
)
assert b'Cannot set language without session cookie' not in res.data
res = client.get(url_for("watchlist.index"), follow_redirects=True)
assert b"Select Language" in res.data, "Second set of language worked"
# Check arbitration between zh_Hant_TW<->zh
res = client.get(
url_for("set_language", locale="zh"), # Simplified chinese
follow_redirects=True
)
res = client.get(url_for("watchlist.index"), follow_redirects=True)
assert "选择语言".encode() in res.data, "Simplified chinese worked and it means the flask-babel cache worked"
# timeago library just hasn't been updated to use the more modern locale naming convention, before BCP 47 / RFC 5646.
# The Python timeago library (https://github.com/hustcc/timeago) supports 48 locales but uses different naming conventions than Flask-Babel.
def test_zh_Hant_TW_timeago_integration():
"""Test that zh_Hant_TW mapping works and timeago renders Traditional Chinese correctly"""
import timeago
from datetime import datetime, timedelta
from changedetectionio.languages import get_timeago_locale
# 1. Test the mapping
mapped_locale = get_timeago_locale('zh_Hant_TW')
assert mapped_locale == 'zh_TW', "zh_Hant_TW should map to timeago's zh_TW"
assert get_timeago_locale('zh_TW') == 'zh_TW', "zh_TW should also map to zh_TW"
# 2. Test timeago library renders Traditional Chinese with the mapped locale
now = datetime.now()
# Test various time periods with Traditional Chinese strings
result_15s = timeago.format(now - timedelta(seconds=15), now, mapped_locale)
assert '秒前' in result_15s, f"Expected '秒前' in '{result_15s}'"
result_5m = timeago.format(now - timedelta(minutes=5), now, mapped_locale)
assert '分鐘前' in result_5m, f"Expected '分鐘前' in '{result_5m}'"
result_2h = timeago.format(now - timedelta(hours=2), now, mapped_locale)
assert '小時前' in result_2h, f"Expected '小時前' in '{result_2h}'"
result_3d = timeago.format(now - timedelta(days=3), now, mapped_locale)
assert '天前' in result_3d, f"Expected '天前' in '{result_3d}'"
from .util import live_server_setup
def test_language_switching(client, live_server, measure_memory_usage, datastore_path):
@@ -77,9 +13,6 @@ def test_language_switching(client, live_server, measure_memory_usage, datastore
3. Switch back to English and verify English text appears
"""
# Establish session cookie
client.get(url_for("watchlist.index"), follow_redirects=True)
# Step 1: Set the language to Italian using the /set-language endpoint
res = client.get(
url_for("set_language", locale="it"),
@@ -128,9 +61,6 @@ def test_invalid_locale(client, live_server, measure_memory_usage, datastore_pat
The app should ignore invalid locales and continue working.
"""
# Establish session cookie
client.get(url_for("watchlist.index"), follow_redirects=True)
# First set to English
res = client.get(
url_for("set_language", locale="en"),
@@ -163,9 +93,6 @@ def test_language_persistence_in_session(client, live_server, measure_memory_usa
within the same session.
"""
# Establish session cookie
client.get(url_for("watchlist.index"), follow_redirects=True)
# Set language to Italian
res = client.get(
url_for("set_language", locale="it"),
@@ -192,9 +119,6 @@ def test_set_language_with_redirect(client, live_server, measure_memory_usage, d
"""
from flask import url_for
# Establish session cookie
client.get(url_for("watchlist.index"), follow_redirects=True)
# Set language with a redirect parameter (simulating language change from /settings)
res = client.get(
url_for("set_language", locale="de", redirect="/settings"),

View File

@@ -2,7 +2,7 @@
import time
from flask import url_for
from .util import live_server_setup, wait_for_all_checks, wait_for_watch_history, extract_rss_token_from_UI, get_UUID_for_tag_name, delete_all_watches
from .util import live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, get_UUID_for_tag_name, delete_all_watches
import os
@@ -87,9 +87,6 @@ def test_rss_group(client, live_server, measure_memory_usage, datastore_path):
# Wait for initial checks to complete
wait_for_all_checks(client)
# Ensure initial snapshots are saved
assert wait_for_watch_history(client, min_history_count=1, timeout=10), "Watches did not save initial snapshots"
# Trigger a change
set_modified_response(datastore_path=datastore_path)
@@ -97,9 +94,6 @@ def test_rss_group(client, live_server, measure_memory_usage, datastore_path):
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
# Ensure all watches have sufficient history for RSS generation
assert wait_for_watch_history(client, min_history_count=2, timeout=10), "Watches did not accumulate sufficient history"
# Get RSS token
rss_token = extract_rss_token_from_UI(client)
assert rss_token is not None
@@ -222,13 +216,11 @@ def test_rss_group_only_unviewed(client, live_server, measure_memory_usage, data
assert b"Watch added" in res.data
wait_for_all_checks(client)
assert wait_for_watch_history(client, min_history_count=1, timeout=10), "Initial snapshots not saved"
# Trigger changes
set_modified_response(datastore_path=datastore_path)
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
assert wait_for_watch_history(client, min_history_count=2, timeout=10), "History not accumulated"
# Get RSS token
rss_token = extract_rss_token_from_UI(client)

View File

@@ -1,10 +1,8 @@
import sys
import os
import pytest
from changedetectionio import html_tools
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import html_tools
# test generation guide.
# 1. Do not include encoding in the xml declaration if the test object is a str type.

View File

@@ -164,45 +164,14 @@ def wait_for_all_checks(client=None):
if q_length == 0 and not any_workers_busy:
if empty_since is None:
empty_since = time.time()
# Brief stabilization period for async workers
elif time.time() - empty_since >= 0.3:
elif time.time() - empty_since >= 0.15: # Shorter wait
break
else:
empty_since = None
attempt += 1
time.sleep(0.3)
def wait_for_watch_history(client, min_history_count=2, timeout=10):
"""
Wait for watches to have sufficient history entries.
Useful after wait_for_all_checks() when you need to ensure history is populated.
Args:
client: Test client with access to datastore
min_history_count: Minimum number of history entries required
timeout: Maximum time to wait in seconds
"""
datastore = client.application.config.get('DATASTORE')
start_time = time.time()
while time.time() - start_time < timeout:
all_have_history = True
for uuid, watch in datastore.data['watching'].items():
history_count = len(watch.history.keys())
if history_count < min_history_count:
all_have_history = False
break
if all_have_history:
return True
time.sleep(0.2)
# Timeout - return False
return False
# Replaced by new_live_server_setup and calling per function scope in conftest.py
def live_server_setup(live_server):
return True
@@ -220,8 +189,6 @@ def new_live_server_setup(live_server):
@live_server.app.route('/test-endpoint')
def test_endpoint():
from loguru import logger
logger.debug(f"/test-endpoint hit {request}")
ctype = request.args.get('content_type')
status_code = request.args.get('status_code')
content = request.args.get('content') or None

View File

@@ -144,6 +144,7 @@ def test_basic_browserstep(client, live_server, measure_memory_usage, datastore_
def test_non_200_errors_report_browsersteps(client, live_server, measure_memory_usage, datastore_path):
four_o_four_url = url_for('test_endpoint', status_code=404, _external=True)
four_o_four_url = four_o_four_url.replace('localhost.localdomain', 'cdio')
four_o_four_url = four_o_four_url.replace('localhost', 'cdio')
@@ -185,65 +186,3 @@ def test_non_200_errors_report_browsersteps(client, live_server, measure_memory_
url_for("ui.form_delete", uuid="all"),
follow_redirects=True
)
def test_browsersteps_edit_UI_startsession(client, live_server, measure_memory_usage, datastore_path):
assert os.getenv('PLAYWRIGHT_DRIVER_URL'), "Needs PLAYWRIGHT_DRIVER_URL set for this test"
# Add a watch first
test_url = url_for('test_interactive_html_endpoint', _external=True)
test_url = test_url.replace('localhost.localdomain', 'cdio')
test_url = test_url.replace('localhost', 'cdio')
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, extras={'fetch_backend': 'html_webdriver', 'paused': True})
# Test starting a browsersteps session
res = client.get(
url_for("browser_steps.browsersteps_start_session", uuid=uuid),
follow_redirects=True
)
assert res.status_code == 200
assert res.is_json
json_data = res.get_json()
assert 'browsersteps_session_id' in json_data
assert json_data['browsersteps_session_id'] # Not empty
browsersteps_session_id = json_data['browsersteps_session_id']
# Verify the session exists in browsersteps_sessions
from changedetectionio.blueprint.browser_steps import browsersteps_sessions, browsersteps_watch_to_session
assert browsersteps_session_id in browsersteps_sessions
assert uuid in browsersteps_watch_to_session
assert browsersteps_watch_to_session[uuid] == browsersteps_session_id
# Verify browsersteps UI shows up on edit page
res = client.get(url_for("ui.ui_edit.edit_page", uuid=uuid))
assert b'browsersteps-click-start' in res.data, "Browsersteps manual UI shows up"
# Session should still exist after GET (not cleaned up yet)
assert browsersteps_session_id in browsersteps_sessions
assert uuid in browsersteps_watch_to_session
# Test cleanup happens on save (POST)
res = client.post(
url_for("ui.ui_edit.edit_page", uuid=uuid),
data={
"url": test_url,
"tags": "",
'fetch_backend': "html_webdriver",
"time_between_check_use_default": "y",
},
follow_redirects=True
)
assert b"Updated watch" in res.data
# NOW verify the session was cleaned up after save
assert browsersteps_session_id not in browsersteps_sessions
assert uuid not in browsersteps_watch_to_session
# Cleanup
client.get(
url_for("ui.form_delete", uuid="all"),
follow_redirects=True
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
#
msgid ""
msgstr ""
"Project-Id-Version: changedetection.io\n"
"Project-Id-Version: changedetection.io\n"
"Report-Msgid-Bugs-To: https://github.com/dgtlmoon/changedetection.io\n"
"POT-Creation-Date: 2026-01-02 16:07+0100\n"
"PO-Revision-Date: 2026-01-12 16:33+0100\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
"Generated-By: Babel 2.10.3\n"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
#: changedetectionio/flask_app.py:213 changedetectionio/flask_app.py:225
@@ -38,11 +38,15 @@ msgid "Incorrect password"
msgstr ""
#: changedetectionio/forms.py:63 changedetectionio/forms.py:243
msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified."
msgid ""
"At least one time interval (weeks, days, hours, minutes, or seconds) must"
" be specified."
msgstr ""
#: changedetectionio/forms.py:64
msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings."
msgid ""
"At least one time interval (weeks, days, hours, minutes, or seconds) must"
" be specified when not using global settings."
msgstr ""
#: changedetectionio/forms.py:164
@@ -585,7 +589,9 @@ msgid "A backup is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html:13
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
msgid ""
"Here you can download and request a new backup, when a backup is "
"completed you will see it listed below."
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html:19
@@ -605,7 +611,9 @@ msgid "Remove backups"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py:45
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
msgid ""
"Importing 5,000 of the first URLs from your list, the rest can be "
"imported again."
msgstr ""
#: changedetectionio/blueprint/imports/importer.py:78
@@ -640,7 +648,9 @@ msgstr ""
#: changedetectionio/blueprint/imports/importer.py:214
#: changedetectionio/blueprint/imports/importer.py:297
#, python-brace-format
msgid "Error processing row number {}, check all cell data types are correct, row was skipped."
msgid ""
"Error processing row number {}, check all cell data types are correct, "
"row was skipped."
msgstr ""
#: changedetectionio/blueprint/imports/importer.py:218
@@ -666,7 +676,9 @@ msgid ".XLSX & Wachete"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:20
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
msgid ""
"Enter one URL per line, and optionally add tags for each URL after a "
"space, delineated by comma (,):"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:22
@@ -678,7 +690,9 @@ msgid "URLs which do not pass validation will stay in the textarea."
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:44
msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
msgid ""
"Copy and Paste your Distill.io watch 'export' file, this should be a JSON"
" file."
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:45
@@ -971,7 +985,9 @@ msgid "Watch group / tag"
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:21
msgid "Groups allows you to manage filters and notifications for multiple watches under a single organisational tag."
msgid ""
"Groups allows you to manage filters and notifications for multiple "
"watches under a single organisational tag."
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
@@ -997,7 +1013,9 @@ msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:59
#, python-format
msgid "<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>"
msgid ""
"<p>Are you sure you want to delete group "
"<strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>"
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:60
@@ -1017,7 +1035,10 @@ msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:67
#, python-format
msgid "<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but watches will be removed from it.</p>"
msgid ""
"<p>Are you sure you want to unlink all watches from group "
"<strong>%(title)s</strong>?</p><p>The tag will be kept but watches will "
"be removed from it.</p>"
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:68
@@ -1134,7 +1155,9 @@ msgstr ""
#: changedetectionio/blueprint/ui/__init__.py:330
#, python-brace-format
msgid "Could not share, something went wrong while communicating with the share server - {}"
msgid ""
"Could not share, something went wrong while communicating with the share "
"server - {}"
msgstr ""
#: changedetectionio/blueprint/ui/diff.py:93
@@ -1147,7 +1170,9 @@ msgid "No history found for the specified link, bad link?"
msgstr ""
#: changedetectionio/blueprint/ui/diff.py:98
msgid "Not enough history (2 snapshots required) to show difference page for this watch."
msgid ""
"Not enough history (2 snapshots required) to show difference page for "
"this watch."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py:35
@@ -1195,7 +1220,9 @@ msgid "Watch added."
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:12
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgid ""
"This will remove version history (snapshots) for ALL watches, but keep "
"your list of URLs!"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:13
@@ -1377,7 +1404,9 @@ msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:144
#: changedetectionio/blueprint/ui/templates/preview.html:80
msgid "For now, Differences are performed on text, not graphically, only the latest screenshot is available."
msgid ""
"For now, Differences are performed on text, not graphically, only the "
"latest screenshot is available."
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:149
@@ -1439,7 +1468,9 @@ msgid "Organisational tag/group name used in the main listing page"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:85
msgid "Automatically uses the page title if found, you can also use your own title/description here"
msgid ""
"Automatically uses the page title if found, you can also use your own "
"title/description here"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:95
@@ -1447,7 +1478,10 @@ msgid "The interval/amount of time between each check."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:110
msgid "Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and your filter will not work anymore."
msgid ""
"Sends a notification when the filter can no longer be seen on the page, "
"good for knowing when the page changed and your filter will not work "
"anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:123
@@ -1459,7 +1493,9 @@ msgid "Basic"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:123
msgid "method (default) where your watched site doesn't need Javascript to render."
msgid ""
"method (default) where your watched site doesn't need Javascript to "
"render."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:124
@@ -1471,7 +1507,9 @@ msgid "Chrome/Javascript"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:124
msgid "method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'."
msgid ""
"method requires a network connection to a running WebDriver+Chrome "
"server, set by the ENV var 'WEBDRIVER_URL'."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:125
@@ -1487,7 +1525,9 @@ msgid "Choose a proxy for this watch"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:143
msgid "If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here."
msgid ""
"If you're having trouble waiting for the page to be fully rendered (text "
"missing etc), try increasing the 'wait' time here."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:145
@@ -1508,7 +1548,9 @@ msgid "Show advanced options"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:157
msgid "Run this code before performing change detection, handy for filling in fields and other actions"
msgid ""
"Run this code before performing change detection, handy for filling in "
"fields and other actions"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:158
@@ -1565,7 +1607,9 @@ msgid "Visual Selector data is not ready, watch needs to be checked atleast once
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:253
msgid "Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based fetchers)"
msgid ""
"Sorry, this functionality only works with fetchers that support "
"interactive Javascript (so far only Playwright based fetchers)"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:254
@@ -1583,7 +1627,9 @@ msgid "to one that supports interactive Javascript."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:297
msgid "Use the verify (✓) button to test if a condition passes against the current snapshot."
msgid ""
"Use the verify (✓) button to test if a condition passes against the "
"current snapshot."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:298
@@ -1611,7 +1657,9 @@ msgid "Limit trigger/ignore/block/extract to;"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:326
msgid "Note: Depending on the length and similarity of the text on each line, the algorithm may consider an"
msgid ""
"Note: Depending on the length and similarity of the text on each line, "
"the algorithm may consider an"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:326
@@ -1652,11 +1700,16 @@ msgid "Only trigger when unique lines appear"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:332
msgid "Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch."
msgid ""
"Good for websites that just move the content around, and you want to know"
" when NEW content is added, compares new lines against all history for "
"this watch."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:340
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with"
msgid ""
"Helps reduce changes detected caused by sites shuffling lines around, "
"combine with"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:340
@@ -1686,7 +1739,9 @@ msgid "text"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:386
msgid "elements that will be used for the change detection. It automatically fills-in the filters in the \"CSS/JSONPath/JQ/XPath Filters\" box of the"
msgid ""
"elements that will be used for the change detection. It automatically "
"fills-in the filters in the \"CSS/JSONPath/JQ/XPath Filters\" box of the"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:386
@@ -1726,7 +1781,9 @@ msgid "Currently:"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:423
msgid "Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc)."
msgid ""
"Sorry, this functionality only works with fetchers that support "
"Javascript and screenshots (such as playwright etc)."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:424
@@ -1782,7 +1839,9 @@ msgid "Are you sure you want to clear all history for:"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:496
msgid "This will remove all snapshots and previous versions. This action cannot be undone."
msgid ""
"This will remove all snapshots and previous versions. This action cannot "
"be undone."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:497
@@ -1806,7 +1865,9 @@ msgid "Current erroring screenshot from most recent request"
msgstr ""
#: changedetectionio/blueprint/ui/templates/preview.html:91
msgid "Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots."
msgid ""
"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc "
") that supports screenshots."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:31
@@ -1875,7 +1936,9 @@ msgid "Clear Histories"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:66
msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
msgid ""
"<p>Are you sure you want to clear history for the selected "
"items?</p><p>This action cannot be undone.</p>"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:67
@@ -1891,7 +1954,9 @@ msgid "Delete Watches?"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:72
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
msgid ""
"<p>Are you sure you want to delete the selected "
"watches?</strong></p><p>This action cannot be undone.</p>"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:78
@@ -1924,7 +1989,7 @@ msgid "Changed"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
msgid "No web page change detection watches configured, please add a URL in the box above, or"
msgid "No website watches configured, please add a URL in the box above, or"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
@@ -2188,7 +2253,9 @@ msgid "Select Language"
msgstr ""
#: changedetectionio/templates/base.html:270
msgid "Language support is in beta, please help us improve by opening a PR on GitHub with any updates."
msgid ""
"Language support is in beta, please help us improve by opening a PR on "
"GitHub with any updates."
msgstr ""
#: changedetectionio/templates/login.html:10
@@ -2199,150 +2266,3 @@ msgstr ""
msgid "Login"
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py:18
#: changedetectionio/widgets/ternary_boolean.py:72
msgid "Yes"
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py:19
#: changedetectionio/widgets/ternary_boolean.py:73
msgid "No"
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py:20
#: changedetectionio/widgets/ternary_boolean.py:74
msgid "Main settings"
msgstr ""
#: changedetectionio/templates/_common_fields.html:11
msgid "Show token/placeholders"
msgstr ""
#: changedetectionio/templates/_common_fields.html:18
msgid "Token"
msgstr ""
#: changedetectionio/templates/_common_fields.html:19
msgid "Description"
msgstr ""
#: changedetectionio/templates/_common_fields.html:128
msgid "Show advanced help and tips"
msgstr ""
#: changedetectionio/templates/_common_fields.html:138
msgid "Send test notification"
msgstr ""
#: changedetectionio/templates/_common_fields.html:140
msgid "Add email"
msgstr ""
#: changedetectionio/templates/_common_fields.html:140
msgid "Add an email address"
msgstr ""
#: changedetectionio/templates/_common_fields.html:142
msgid "Notification debug logs"
msgstr ""
#: changedetectionio/templates/_common_fields.html:144
msgid "Processing.."
msgstr ""
#: changedetectionio/templates/_common_fields.html:151
msgid "Title for all notifications"
msgstr ""
#: changedetectionio/templates/_common_fields.html:176
msgid "Format for all notifications"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:9
msgid "Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive."
msgstr ""
#: changedetectionio/templates/edit/text-options.html:10
msgid "Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:11
msgid "Each line is processed separately (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:12
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:23
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:24
msgid "Each line processed separately, any line matching will be ignored (removed before creating the checksum)"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:25
msgid "Regular Expression support, wrap the entire line in forward slash"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:26
msgid "Changing this will affect the comparison checksum which may trigger an alert"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:43
msgid "Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for waiting for when a product is available again"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:44
msgid "Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:45
msgid "All lines here must not exist (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:58
msgid "Extracts text in the final output (line by line) after other filters using regular expressions or string match:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:60
msgid "Regular expression - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:61
msgid "Don't forget to consider the white-space at the start of a line"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
msgid "Use"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
msgid "type flags (more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
msgid "information here"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:63
msgid "Keyword example - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:64
msgid "Use groups to extract just that text - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:64
msgid "returns a list of years only"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:65
msgid "Example - match lines containing a keyword"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:68
msgid "One line per regular-expression/string match"
msgstr ""

View File

@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
"Generated-By: Babel 2.10.3\n"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
#: changedetectionio/flask_app.py:213 changedetectionio/flask_app.py:225
@@ -38,11 +38,15 @@ msgid "Incorrect password"
msgstr ""
#: changedetectionio/forms.py:63 changedetectionio/forms.py:243
msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified."
msgid ""
"At least one time interval (weeks, days, hours, minutes, or seconds) must"
" be specified."
msgstr ""
#: changedetectionio/forms.py:64
msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings."
msgid ""
"At least one time interval (weeks, days, hours, minutes, or seconds) must"
" be specified when not using global settings."
msgstr ""
#: changedetectionio/forms.py:164
@@ -585,7 +589,9 @@ msgid "A backup is running!"
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html:13
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
msgid ""
"Here you can download and request a new backup, when a backup is "
"completed you will see it listed below."
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html:19
@@ -605,7 +611,9 @@ msgid "Remove backups"
msgstr ""
#: changedetectionio/blueprint/imports/importer.py:45
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
msgid ""
"Importing 5,000 of the first URLs from your list, the rest can be "
"imported again."
msgstr ""
#: changedetectionio/blueprint/imports/importer.py:78
@@ -640,7 +648,9 @@ msgstr ""
#: changedetectionio/blueprint/imports/importer.py:214
#: changedetectionio/blueprint/imports/importer.py:297
#, python-brace-format
msgid "Error processing row number {}, check all cell data types are correct, row was skipped."
msgid ""
"Error processing row number {}, check all cell data types are correct, "
"row was skipped."
msgstr ""
#: changedetectionio/blueprint/imports/importer.py:218
@@ -666,7 +676,9 @@ msgid ".XLSX & Wachete"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:20
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
msgid ""
"Enter one URL per line, and optionally add tags for each URL after a "
"space, delineated by comma (,):"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:22
@@ -678,7 +690,9 @@ msgid "URLs which do not pass validation will stay in the textarea."
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:44
msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
msgid ""
"Copy and Paste your Distill.io watch 'export' file, this should be a JSON"
" file."
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:45
@@ -971,7 +985,9 @@ msgid "Watch group / tag"
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:21
msgid "Groups allows you to manage filters and notifications for multiple watches under a single organisational tag."
msgid ""
"Groups allows you to manage filters and notifications for multiple "
"watches under a single organisational tag."
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
@@ -997,7 +1013,9 @@ msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:59
#, python-format
msgid "<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>"
msgid ""
"<p>Are you sure you want to delete group "
"<strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>"
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:60
@@ -1017,7 +1035,10 @@ msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:67
#, python-format
msgid "<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but watches will be removed from it.</p>"
msgid ""
"<p>Are you sure you want to unlink all watches from group "
"<strong>%(title)s</strong>?</p><p>The tag will be kept but watches will "
"be removed from it.</p>"
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:68
@@ -1134,7 +1155,9 @@ msgstr ""
#: changedetectionio/blueprint/ui/__init__.py:330
#, python-brace-format
msgid "Could not share, something went wrong while communicating with the share server - {}"
msgid ""
"Could not share, something went wrong while communicating with the share "
"server - {}"
msgstr ""
#: changedetectionio/blueprint/ui/diff.py:93
@@ -1147,7 +1170,9 @@ msgid "No history found for the specified link, bad link?"
msgstr ""
#: changedetectionio/blueprint/ui/diff.py:98
msgid "Not enough history (2 snapshots required) to show difference page for this watch."
msgid ""
"Not enough history (2 snapshots required) to show difference page for "
"this watch."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py:35
@@ -1195,7 +1220,9 @@ msgid "Watch added."
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:12
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgid ""
"This will remove version history (snapshots) for ALL watches, but keep "
"your list of URLs!"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:13
@@ -1377,7 +1404,9 @@ msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:144
#: changedetectionio/blueprint/ui/templates/preview.html:80
msgid "For now, Differences are performed on text, not graphically, only the latest screenshot is available."
msgid ""
"For now, Differences are performed on text, not graphically, only the "
"latest screenshot is available."
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:149
@@ -1439,7 +1468,9 @@ msgid "Organisational tag/group name used in the main listing page"
msgstr "organizational tag/group name used in the main listing page"
#: changedetectionio/blueprint/ui/templates/edit.html:85
msgid "Automatically uses the page title if found, you can also use your own title/description here"
msgid ""
"Automatically uses the page title if found, you can also use your own "
"title/description here"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:95
@@ -1447,7 +1478,10 @@ msgid "The interval/amount of time between each check."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:110
msgid "Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and your filter will not work anymore."
msgid ""
"Sends a notification when the filter can no longer be seen on the page, "
"good for knowing when the page changed and your filter will not work "
"anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:123
@@ -1459,7 +1493,9 @@ msgid "Basic"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:123
msgid "method (default) where your watched site doesn't need Javascript to render."
msgid ""
"method (default) where your watched site doesn't need Javascript to "
"render."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:124
@@ -1471,7 +1507,9 @@ msgid "Chrome/Javascript"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:124
msgid "method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'."
msgid ""
"method requires a network connection to a running WebDriver+Chrome "
"server, set by the ENV var 'WEBDRIVER_URL'."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:125
@@ -1487,7 +1525,9 @@ msgid "Choose a proxy for this watch"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:143
msgid "If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here."
msgid ""
"If you're having trouble waiting for the page to be fully rendered (text "
"missing etc), try increasing the 'wait' time here."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:145
@@ -1508,7 +1548,9 @@ msgid "Show advanced options"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:157
msgid "Run this code before performing change detection, handy for filling in fields and other actions"
msgid ""
"Run this code before performing change detection, handy for filling in "
"fields and other actions"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:158
@@ -1565,7 +1607,9 @@ msgid "Visual Selector data is not ready, watch needs to be checked atleast once
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:253
msgid "Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based fetchers)"
msgid ""
"Sorry, this functionality only works with fetchers that support "
"interactive Javascript (so far only Playwright based fetchers)"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:254
@@ -1583,7 +1627,9 @@ msgid "to one that supports interactive Javascript."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:297
msgid "Use the verify (✓) button to test if a condition passes against the current snapshot."
msgid ""
"Use the verify (✓) button to test if a condition passes against the "
"current snapshot."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:298
@@ -1611,7 +1657,9 @@ msgid "Limit trigger/ignore/block/extract to;"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:326
msgid "Note: Depending on the length and similarity of the text on each line, the algorithm may consider an"
msgid ""
"Note: Depending on the length and similarity of the text on each line, "
"the algorithm may consider an"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:326
@@ -1652,11 +1700,16 @@ msgid "Only trigger when unique lines appear"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:332
msgid "Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch."
msgid ""
"Good for websites that just move the content around, and you want to know"
" when NEW content is added, compares new lines against all history for "
"this watch."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:340
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with"
msgid ""
"Helps reduce changes detected caused by sites shuffling lines around, "
"combine with"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:340
@@ -1686,7 +1739,9 @@ msgid "text"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:386
msgid "elements that will be used for the change detection. It automatically fills-in the filters in the \"CSS/JSONPath/JQ/XPath Filters\" box of the"
msgid ""
"elements that will be used for the change detection. It automatically "
"fills-in the filters in the \"CSS/JSONPath/JQ/XPath Filters\" box of the"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:386
@@ -1726,7 +1781,9 @@ msgid "Currently:"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:423
msgid "Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc)."
msgid ""
"Sorry, this functionality only works with fetchers that support "
"Javascript and screenshots (such as playwright etc)."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:424
@@ -1782,7 +1839,9 @@ msgid "Are you sure you want to clear all history for:"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:496
msgid "This will remove all snapshots and previous versions. This action cannot be undone."
msgid ""
"This will remove all snapshots and previous versions. This action cannot "
"be undone."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:497
@@ -1806,7 +1865,9 @@ msgid "Current erroring screenshot from most recent request"
msgstr ""
#: changedetectionio/blueprint/ui/templates/preview.html:91
msgid "Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots."
msgid ""
"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc "
") that supports screenshots."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:31
@@ -1875,7 +1936,9 @@ msgid "Clear Histories"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:66
msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
msgid ""
"<p>Are you sure you want to clear history for the selected "
"items?</p><p>This action cannot be undone.</p>"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:67
@@ -1891,7 +1954,9 @@ msgid "Delete Watches?"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:72
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
msgid ""
"<p>Are you sure you want to delete the selected "
"watches?</strong></p><p>This action cannot be undone.</p>"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:78
@@ -1924,7 +1989,7 @@ msgid "Changed"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
msgid "No web page change detection watches configured, please add a URL in the box above, or"
msgid "No website watches configured, please add a URL in the box above, or"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
@@ -2188,7 +2253,9 @@ msgid "Select Language"
msgstr ""
#: changedetectionio/templates/base.html:270
msgid "Language support is in beta, please help us improve by opening a PR on GitHub with any updates."
msgid ""
"Language support is in beta, please help us improve by opening a PR on "
"GitHub with any updates."
msgstr ""
#: changedetectionio/templates/login.html:10
@@ -2199,150 +2266,3 @@ msgstr ""
msgid "Login"
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py:18
#: changedetectionio/widgets/ternary_boolean.py:72
msgid "Yes"
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py:19
#: changedetectionio/widgets/ternary_boolean.py:73
msgid "No"
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py:20
#: changedetectionio/widgets/ternary_boolean.py:74
msgid "Main settings"
msgstr ""
#: changedetectionio/templates/_common_fields.html:11
msgid "Show token/placeholders"
msgstr ""
#: changedetectionio/templates/_common_fields.html:18
msgid "Token"
msgstr ""
#: changedetectionio/templates/_common_fields.html:19
msgid "Description"
msgstr ""
#: changedetectionio/templates/_common_fields.html:128
msgid "Show advanced help and tips"
msgstr ""
#: changedetectionio/templates/_common_fields.html:138
msgid "Send test notification"
msgstr ""
#: changedetectionio/templates/_common_fields.html:140
msgid "Add email"
msgstr ""
#: changedetectionio/templates/_common_fields.html:140
msgid "Add an email address"
msgstr ""
#: changedetectionio/templates/_common_fields.html:142
msgid "Notification debug logs"
msgstr ""
#: changedetectionio/templates/_common_fields.html:144
msgid "Processing.."
msgstr ""
#: changedetectionio/templates/_common_fields.html:151
msgid "Title for all notifications"
msgstr ""
#: changedetectionio/templates/_common_fields.html:176
msgid "Format for all notifications"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:9
msgid "Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive."
msgstr ""
#: changedetectionio/templates/edit/text-options.html:10
msgid "Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:11
msgid "Each line is processed separately (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:12
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:23
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:24
msgid "Each line processed separately, any line matching will be ignored (removed before creating the checksum)"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:25
msgid "Regular Expression support, wrap the entire line in forward slash"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:26
msgid "Changing this will affect the comparison checksum which may trigger an alert"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:43
msgid "Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for waiting for when a product is available again"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:44
msgid "Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:45
msgid "All lines here must not exist (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:58
msgid "Extracts text in the final output (line by line) after other filters using regular expressions or string match:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:60
msgid "Regular expression - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:61
msgid "Don't forget to consider the white-space at the start of a line"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
msgid "Use"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
msgid "type flags (more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
msgid "information here"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:63
msgid "Keyword example - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:64
msgid "Use groups to extract just that text - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:64
msgid "returns a list of years only"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:65
msgid "Example - match lines containing a keyword"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:68
msgid "One line per regular-expression/string match"
msgstr ""

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-01-02 16:07+0100\n"
"POT-Creation-Date: 2026-01-03 14:31+0100\n"
"PO-Revision-Date: 2026-01-02 15:32+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: it\n"
@@ -19,30 +19,34 @@ msgstr ""
"Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
#: changedetectionio/flask_app.py:213 changedetectionio/flask_app.py:225
#: changedetectionio/flask_app.py:246
#: changedetectionio/flask_app.py:214 changedetectionio/flask_app.py:226
#: changedetectionio/flask_app.py:247
#: changedetectionio/realtime/socket_server.py:171
msgid "Not yet"
msgstr "Non ancora"
#: changedetectionio/flask_app.py:468
msgid "You must be logged in, please log in."
msgstr "Devi essere autenticato, effettua l'accesso."
#: changedetectionio/flask_app.py:495
#: changedetectionio/flask_app.py:534
msgid "Already logged in"
msgstr "Già autenticato"
#: changedetectionio/flask_app.py:522
#: changedetectionio/flask_app.py:536
msgid "You must be logged in, please log in."
msgstr "Devi essere autenticato, effettua l'accesso."
#: changedetectionio/flask_app.py:551
msgid "Incorrect password"
msgstr "Password errata"
#: changedetectionio/forms.py:63 changedetectionio/forms.py:243
msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified."
msgid ""
"At least one time interval (weeks, days, hours, minutes, or seconds) must"
" be specified."
msgstr ""
#: changedetectionio/forms.py:64
msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings."
msgid ""
"At least one time interval (weeks, days, hours, minutes, or seconds) must"
" be specified when not using global settings."
msgstr ""
#: changedetectionio/forms.py:164
@@ -365,8 +369,9 @@ msgid "Muted"
msgstr "Silenzia"
#: changedetectionio/forms.py:832
#, fuzzy
msgid "On"
msgstr "Attivo"
msgstr "nessuno"
#: changedetectionio/forms.py:833
msgid "Attach screenshot to notification (where possible)"
@@ -596,6 +601,8 @@ msgid "Backups were deleted."
msgstr "I backup sono stati eliminati."
#: changedetectionio/blueprint/backups/templates/overview.html:6
#: changedetectionio/templates/base.html:282
#: changedetectionio/templates/base.html:290
msgid "Backups"
msgstr "Backup"
@@ -604,7 +611,9 @@ msgid "A backup is running!"
msgstr "Un backup è in esecuzione!"
#: changedetectionio/blueprint/backups/templates/overview.html:13
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
msgid ""
"Here you can download and request a new backup, when a backup is "
"completed you will see it listed below."
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html:19
@@ -624,8 +633,12 @@ msgid "Remove backups"
msgstr "Rimuovi backup"
#: changedetectionio/blueprint/imports/importer.py:45
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
msgstr "Importazione delle prime 5.000 URL dalla tua lista, il resto può essere importato di nuovo."
msgid ""
"Importing 5,000 of the first URLs from your list, the rest can be "
"imported again."
msgstr ""
"Importazione delle prime 5.000 URL dalla tua lista, il resto può essere "
"importato di nuovo."
#: changedetectionio/blueprint/imports/importer.py:78
#, python-brace-format
@@ -648,19 +661,27 @@ msgstr "{} Importate da Distill.io in {:.2f}s, {} Ignorate."
#: changedetectionio/blueprint/imports/importer.py:160
#: changedetectionio/blueprint/imports/importer.py:239
msgid "Unable to read export XLSX file, something wrong with the file?"
msgstr "Impossibile leggere il file XLSX di esportazione, c'è qualcosa che non va con il file?"
msgstr ""
"Impossibile leggere il file XLSX di esportazione, c'è qualcosa che non va"
" con il file?"
#: changedetectionio/blueprint/imports/importer.py:200
#: changedetectionio/blueprint/imports/importer.py:268
#, python-brace-format
msgid "Error processing row number {}, URL value was incorrect, row was skipped."
msgstr "Errore nell'elaborazione della riga numero {}, il valore dell'URL non era corretto, riga ignorata."
msgstr ""
"Errore nell'elaborazione della riga numero {}, il valore dell'URL non era"
" corretto, riga ignorata."
#: changedetectionio/blueprint/imports/importer.py:214
#: changedetectionio/blueprint/imports/importer.py:297
#, python-brace-format
msgid "Error processing row number {}, check all cell data types are correct, row was skipped."
msgstr "Errore nell'elaborazione della riga numero {}, verifica che tutti i tipi di dati delle celle siano corretti, riga ignorata."
msgid ""
"Error processing row number {}, check all cell data types are correct, "
"row was skipped."
msgstr ""
"Errore nell'elaborazione della riga numero {}, verifica che tutti i tipi "
"di dati delle celle siano corretti, riga ignorata."
#: changedetectionio/blueprint/imports/importer.py:218
#, python-brace-format
@@ -685,7 +706,9 @@ msgid ".XLSX & Wachete"
msgstr ".XLSX & Wachete"
#: changedetectionio/blueprint/imports/templates/import.html:20
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
msgid ""
"Enter one URL per line, and optionally add tags for each URL after a "
"space, delineated by comma (,):"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:22
@@ -697,7 +720,9 @@ msgid "URLs which do not pass validation will stay in the textarea."
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:44
msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
msgid ""
"Copy and Paste your Distill.io watch 'export' file, this should be a JSON"
" file."
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:45
@@ -990,7 +1015,9 @@ msgid "Watch group / tag"
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:21
msgid "Groups allows you to manage filters and notifications for multiple watches under a single organisational tag."
msgid ""
"Groups allows you to manage filters and notifications for multiple "
"watches under a single organisational tag."
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
@@ -1016,7 +1043,9 @@ msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:59
#, python-format
msgid "<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>"
msgid ""
"<p>Are you sure you want to delete group "
"<strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>"
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:60
@@ -1036,7 +1065,10 @@ msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:67
#, python-format
msgid "<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but watches will be removed from it.</p>"
msgid ""
"<p>Are you sure you want to unlink all watches from group "
"<strong>%(title)s</strong>?</p><p>The tag will be kept but watches will "
"be removed from it.</p>"
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:68
@@ -1153,7 +1185,9 @@ msgstr ""
#: changedetectionio/blueprint/ui/__init__.py:330
#, python-brace-format
msgid "Could not share, something went wrong while communicating with the share server - {}"
msgid ""
"Could not share, something went wrong while communicating with the share "
"server - {}"
msgstr ""
#: changedetectionio/blueprint/ui/diff.py:93
@@ -1166,7 +1200,9 @@ msgid "No history found for the specified link, bad link?"
msgstr ""
#: changedetectionio/blueprint/ui/diff.py:98
msgid "Not enough history (2 snapshots required) to show difference page for this watch."
msgid ""
"Not enough history (2 snapshots required) to show difference page for "
"this watch."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py:35
@@ -1214,7 +1250,9 @@ msgid "Watch added."
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:12
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgid ""
"This will remove version history (snapshots) for ALL watches, but keep "
"your list of URLs!"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:13
@@ -1250,7 +1288,8 @@ msgid "Clear History!"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:39
#: changedetectionio/templates/base.html:274
#: changedetectionio/templates/base.html:379
#: changedetectionio/templates/base.html:399
msgid "Cancel"
msgstr "Annulla"
@@ -1396,7 +1435,9 @@ msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:144
#: changedetectionio/blueprint/ui/templates/preview.html:80
msgid "For now, Differences are performed on text, not graphically, only the latest screenshot is available."
msgid ""
"For now, Differences are performed on text, not graphically, only the "
"latest screenshot is available."
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:149
@@ -1458,7 +1499,9 @@ msgid "Organisational tag/group name used in the main listing page"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:85
msgid "Automatically uses the page title if found, you can also use your own title/description here"
msgid ""
"Automatically uses the page title if found, you can also use your own "
"title/description here"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:95
@@ -1466,7 +1509,10 @@ msgid "The interval/amount of time between each check."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:110
msgid "Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and your filter will not work anymore."
msgid ""
"Sends a notification when the filter can no longer be seen on the page, "
"good for knowing when the page changed and your filter will not work "
"anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:123
@@ -1478,7 +1524,9 @@ msgid "Basic"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:123
msgid "method (default) where your watched site doesn't need Javascript to render."
msgid ""
"method (default) where your watched site doesn't need Javascript to "
"render."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:124
@@ -1490,7 +1538,9 @@ msgid "Chrome/Javascript"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:124
msgid "method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'."
msgid ""
"method requires a network connection to a running WebDriver+Chrome "
"server, set by the ENV var 'WEBDRIVER_URL'."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:125
@@ -1506,7 +1556,9 @@ msgid "Choose a proxy for this watch"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:143
msgid "If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here."
msgid ""
"If you're having trouble waiting for the page to be fully rendered (text "
"missing etc), try increasing the 'wait' time here."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:145
@@ -1527,7 +1579,9 @@ msgid "Show advanced options"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:157
msgid "Run this code before performing change detection, handy for filling in fields and other actions"
msgid ""
"Run this code before performing change detection, handy for filling in "
"fields and other actions"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:158
@@ -1584,7 +1638,9 @@ msgid "Visual Selector data is not ready, watch needs to be checked atleast once
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:253
msgid "Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based fetchers)"
msgid ""
"Sorry, this functionality only works with fetchers that support "
"interactive Javascript (so far only Playwright based fetchers)"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:254
@@ -1602,7 +1658,9 @@ msgid "to one that supports interactive Javascript."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:297
msgid "Use the verify (✓) button to test if a condition passes against the current snapshot."
msgid ""
"Use the verify (✓) button to test if a condition passes against the "
"current snapshot."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:298
@@ -1630,7 +1688,9 @@ msgid "Limit trigger/ignore/block/extract to;"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:326
msgid "Note: Depending on the length and similarity of the text on each line, the algorithm may consider an"
msgid ""
"Note: Depending on the length and similarity of the text on each line, "
"the algorithm may consider an"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:326
@@ -1671,11 +1731,16 @@ msgid "Only trigger when unique lines appear"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:332
msgid "Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch."
msgid ""
"Good for websites that just move the content around, and you want to know"
" when NEW content is added, compares new lines against all history for "
"this watch."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:340
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with"
msgid ""
"Helps reduce changes detected caused by sites shuffling lines around, "
"combine with"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:340
@@ -1705,7 +1770,9 @@ msgid "text"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:386
msgid "elements that will be used for the change detection. It automatically fills-in the filters in the \"CSS/JSONPath/JQ/XPath Filters\" box of the"
msgid ""
"elements that will be used for the change detection. It automatically "
"fills-in the filters in the \"CSS/JSONPath/JQ/XPath Filters\" box of the"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:386
@@ -1745,7 +1812,9 @@ msgid "Currently:"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:423
msgid "Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc)."
msgid ""
"Sorry, this functionality only works with fetchers that support "
"Javascript and screenshots (such as playwright etc)."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:424
@@ -1801,7 +1870,9 @@ msgid "Are you sure you want to clear all history for:"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:496
msgid "This will remove all snapshots and previous versions. This action cannot be undone."
msgid ""
"This will remove all snapshots and previous versions. This action cannot "
"be undone."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:497
@@ -1825,7 +1896,9 @@ msgid "Current erroring screenshot from most recent request"
msgstr ""
#: changedetectionio/blueprint/ui/templates/preview.html:91
msgid "Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots."
msgid ""
"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc "
") that supports screenshots."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:31
@@ -1894,7 +1967,9 @@ msgid "Clear Histories"
msgstr "Cancella cronologie"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:66
msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
msgid ""
"<p>Are you sure you want to clear history for the selected "
"items?</p><p>This action cannot be undone.</p>"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:67
@@ -1910,7 +1985,9 @@ msgid "Delete Watches?"
msgstr "Eliminare monitoraggi?"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:72
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
msgid ""
"<p>Are you sure you want to delete the selected "
"watches?</strong></p><p>This action cannot be undone.</p>"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:78
@@ -1943,7 +2020,7 @@ msgid "Changed"
msgstr "Modifica"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
msgid "No web page change detection watches configured, please add a URL in the box above, or"
msgid "No website watches configured, please add a URL in the box above, or"
msgstr "Nessun monitoraggio configurato, aggiungi un URL nella casella sopra, oppure"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
@@ -1971,7 +2048,7 @@ msgid "No information"
msgstr "Nessuna informazione"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:234
#: changedetectionio/templates/base.html:248
#: changedetectionio/templates/base.html:353
msgid "Checking now"
msgstr "Controllo in corso"
@@ -2038,7 +2115,9 @@ msgstr "Valore riquadro di selezione troppo lungo"
#: changedetectionio/processors/image_ssim_diff/forms.py:23
msgid "Bounding box must be in format: x,y,width,height (integers only)"
msgstr "Il riquadro deve essere nel formato: x,y,larghezza,altezza (solo numeri interi)"
msgstr ""
"Il riquadro deve essere nel formato: x,y,larghezza,altezza (solo numeri "
"interi)"
#: changedetectionio/processors/image_ssim_diff/forms.py:29
msgid "Bounding box values must be non-negative"
@@ -2091,7 +2170,9 @@ msgstr "Rilevamento modifiche screenshot visivi"
#: changedetectionio/processors/image_ssim_diff/processor.py:22
msgid "Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM"
msgstr "Confronta screenshot con algoritmo OpenCV veloce, 10-100x più veloce di SSIM"
msgstr ""
"Confronta screenshot con algoritmo OpenCV veloce, 10-100x più veloce di "
"SSIM"
#: changedetectionio/processors/restock_diff/forms.py:15
msgid "Re-stock detection"
@@ -2155,338 +2236,264 @@ msgstr "Modifiche testo/HTML, JSON e PDF"
msgid "Detects all text changes where possible"
msgstr "Rileva tutte le modifiche di testo possibili"
#: changedetectionio/templates/base.html:77
#: changedetectionio/templates/_helpers.html:25
msgid "Entry"
msgstr ""
#: changedetectionio/templates/_helpers.html:153
#, fuzzy
msgid "Actions"
msgstr "Condizioni"
#: changedetectionio/templates/_helpers.html:172
msgid "Add a row/rule after"
msgstr ""
#: changedetectionio/templates/_helpers.html:173
msgid "Remove this row/rule"
msgstr ""
#: changedetectionio/templates/_helpers.html:174
msgid "Verify this rule against current snapshot"
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid ""
"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but "
"Chrome based fetching is not enabled."
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid "Alternatively try our"
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid ""
"very affordable subscription based service which has all this setup for "
"you"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
msgid "You may need to"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
msgid "Enable playwright environment variable"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
msgid "and uncomment the"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
#, fuzzy
msgid "in the"
msgstr "Silenzia"
#: changedetectionio/templates/_helpers.html:185
#, fuzzy
msgid "file"
msgstr "Titolo"
#: changedetectionio/templates/_helpers.html:240
msgid "Set a hourly/week day schedule"
msgstr ""
#: changedetectionio/templates/_helpers.html:247
#, fuzzy
msgid "Schedule time limits"
msgstr "Tempo di ricontrollo (minuti)"
#: changedetectionio/templates/_helpers.html:248
msgid "Business hours"
msgstr ""
#: changedetectionio/templates/_helpers.html:249
#, fuzzy
msgid "Weekends"
msgstr "Settimane"
#: changedetectionio/templates/_helpers.html:250
#, fuzzy
msgid "Reset"
msgstr "Richiesta"
#: changedetectionio/templates/_helpers.html:259
msgid ""
"Warning, one or more of your 'days' has a duration that would extend into"
" the next day."
msgstr ""
#: changedetectionio/templates/_helpers.html:260
msgid "This could have unintended consequences."
msgstr ""
#: changedetectionio/templates/_helpers.html:270
msgid "More help and examples about using the scheduler"
msgstr ""
#: changedetectionio/templates/_helpers.html:275
#, fuzzy
msgid "Want to use a time schedule?"
msgstr "Usa pianificazione oraria"
#: changedetectionio/templates/_helpers.html:275
msgid "First confirm/save your Time Zone Settings"
msgstr ""
#: changedetectionio/templates/_helpers.html:284
msgid ""
"Triggers a change if this text appears, AND something changed in the "
"document."
msgstr ""
#: changedetectionio/templates/_helpers.html:284
#, fuzzy
msgid "Triggered text"
msgstr "Ignora testo"
#: changedetectionio/templates/_helpers.html:285
msgid "Ignored for calculating changes, but still shown."
msgstr ""
#: changedetectionio/templates/_helpers.html:285
#, fuzzy
msgid "Ignored text"
msgstr "Ignora testo"
#: changedetectionio/templates/_helpers.html:286
#, fuzzy
msgid "No change-detection will occur because this text exists."
msgstr "Blocca rilevamento modifiche quando il testo corrisponde"
#: changedetectionio/templates/_helpers.html:286
#, fuzzy
msgid "Blocked text"
msgstr "Ignora testo"
#: changedetectionio/templates/base.html:78
#: changedetectionio/templates/base.html:168
msgid "GROUPS"
msgstr "GRUPPI"
#: changedetectionio/templates/base.html:80
#: changedetectionio/templates/base.html:81
#: changedetectionio/templates/base.html:169
msgid "SETTINGS"
msgstr "IMPOSTAZIONI"
#: changedetectionio/templates/base.html:83
#: changedetectionio/templates/base.html:84
#: changedetectionio/templates/base.html:170
msgid "IMPORT"
msgstr "IMPORTA"
#: changedetectionio/templates/base.html:86
#: changedetectionio/templates/base.html:87
#: changedetectionio/templates/base.html:171
msgid "BACKUPS"
msgstr "BACKUP"
#: changedetectionio/templates/base.html:90
#: changedetectionio/templates/base.html:91
#: changedetectionio/templates/base.html:173
msgid "EDIT"
msgstr "MODIFICA"
#: changedetectionio/templates/base.html:100
#: changedetectionio/templates/base.html:101
#: changedetectionio/templates/base.html:177
msgid "LOG OUT"
msgstr "ESCI"
#: changedetectionio/templates/base.html:109
#: changedetectionio/templates/base.html:108
msgid "Search, or Use Alt+S Key"
msgstr "Cerca, o usa il tasto Alt+S"
#: changedetectionio/templates/base.html:116
#: changedetectionio/templates/base.html:114
msgid "Toggle Light/Dark Mode"
msgstr "Cambia Modalità Chiaro/Scuro"
#: changedetectionio/templates/base.html:117
#: changedetectionio/templates/base.html:115
msgid "Toggle light/dark mode"
msgstr "Cambia modalità chiaro/scuro"
#: changedetectionio/templates/base.html:127
#: changedetectionio/templates/base.html:125
msgid "Change Language"
msgstr "Cambia Lingua"
#: changedetectionio/templates/base.html:128
#: changedetectionio/templates/base.html:126
msgid "Change language"
msgstr "Cambia lingua"
#: changedetectionio/templates/base.html:249
#: changedetectionio/templates/base.html:253
#, fuzzy
msgid "Watch List"
msgstr "Lista Monitoraggi"
#: changedetectionio/templates/base.html:258
#, fuzzy
msgid "Watches"
msgstr "Monitoraggi"
#: changedetectionio/templates/base.html:261
msgid "Queue Status"
msgstr ""
#: changedetectionio/templates/base.html:270
#, fuzzy
msgid "Queue"
msgstr "In coda"
#: changedetectionio/templates/base.html:274
#: changedetectionio/templates/base.html:279
#, fuzzy
msgid "Settings"
msgstr "IMPOSTAZIONI"
#: changedetectionio/templates/base.html:293
msgid "Sitemap Crawler"
msgstr ""
#: changedetectionio/templates/base.html:318
msgid "Sitemap"
msgstr ""
#: changedetectionio/templates/base.html:354
msgid "Real-time updates offline"
msgstr ""
#: changedetectionio/templates/base.html:259
#: changedetectionio/templates/base.html:364
msgid "Select Language"
msgstr "Seleziona Lingua"
#: changedetectionio/templates/base.html:270
msgid "Language support is in beta, please help us improve by opening a PR on GitHub with any updates."
msgstr "Il supporto linguistico è in versione beta, aiutaci a migliorare aprendo una PR su GitHub con eventuali aggiornamenti."
#: changedetectionio/templates/base.html:375
msgid ""
"Language support is in beta, please help us improve by opening a PR on "
"GitHub with any updates."
msgstr ""
"Il supporto linguistico è in versione beta, aiutaci a migliorare aprendo "
"una PR su GitHub con eventuali aggiornamenti."
#: changedetectionio/templates/login.html:10
#: changedetectionio/templates/base.html:387
#: changedetectionio/templates/base.html:400
#, fuzzy
msgid "Search"
msgstr "Ricerca in corso"
#: changedetectionio/templates/base.html:392
msgid "URL or Title"
msgstr ""
#: changedetectionio/templates/base.html:392
#, fuzzy
msgid "in"
msgstr "Info"
#: changedetectionio/templates/base.html:393
msgid "Enter search term..."
msgstr ""
#: changedetectionio/templates/login.html:11
msgid "Password"
msgstr "Password"
#: changedetectionio/templates/login.html:16
#: changedetectionio/templates/login.html:17
msgid "Login"
msgstr "Accedi"
#: changedetectionio/widgets/ternary_boolean.py:18
#: changedetectionio/widgets/ternary_boolean.py:72
msgid "Yes"
msgstr "Sì"
#: changedetectionio/widgets/ternary_boolean.py:19
#: changedetectionio/widgets/ternary_boolean.py:73
msgid "No"
msgstr "No"
#: changedetectionio/widgets/ternary_boolean.py:20
#: changedetectionio/widgets/ternary_boolean.py:74
msgid "Main settings"
msgstr "Impostazioni principali"
#: changedetectionio/templates/_common_fields.html:11
msgid "Show token/placeholders"
msgstr ""
#: changedetectionio/templates/_common_fields.html:18
msgid "Token"
msgstr ""
#: changedetectionio/templates/_common_fields.html:19
msgid "Description"
msgstr ""
#: changedetectionio/templates/_common_fields.html:128
msgid "Show advanced help and tips"
msgstr ""
#: changedetectionio/templates/_common_fields.html:138
#, fuzzy
msgid "Send test notification"
msgstr "Usa notifica predefinita"
#: changedetectionio/templates/_common_fields.html:140
msgid "Add email"
msgstr ""
#: changedetectionio/templates/_common_fields.html:140
msgid "Add an email address"
msgstr ""
#: changedetectionio/templates/_common_fields.html:142
#, fuzzy
msgid "Notification debug logs"
msgstr "Notifiche"
#: changedetectionio/templates/_common_fields.html:144
#, fuzzy
msgid "Processing.."
msgstr "Processore"
#: changedetectionio/templates/_common_fields.html:151
#, fuzzy
msgid "Title for all notifications"
msgstr "Usa notifica predefinita"
#: changedetectionio/templates/_common_fields.html:176
#, fuzzy
msgid "Format for all notifications"
msgstr "Usa notifica predefinita"
#: changedetectionio/templates/edit/text-options.html:9
msgid "Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive."
msgstr ""
#: changedetectionio/templates/edit/text-options.html:10
msgid "Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:11
msgid "Each line is processed separately (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:12
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:23
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:24
msgid "Each line processed separately, any line matching will be ignored (removed before creating the checksum)"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:25
msgid "Regular Expression support, wrap the entire line in forward slash"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:26
msgid "Changing this will affect the comparison checksum which may trigger an alert"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:43
msgid "Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for waiting for when a product is available again"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:44
msgid "Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:45
msgid "All lines here must not exist (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:58
msgid "Extracts text in the final output (line by line) after other filters using regular expressions or string match:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:60
msgid "Regular expression - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:61
msgid "Don't forget to consider the white-space at the start of a line"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
#, fuzzy
msgid "Use"
msgstr "Pausa"
#: changedetectionio/templates/edit/text-options.html:62
msgid "type flags (more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
#, fuzzy
msgid "information here"
msgstr "Nessuna informazione"
#: changedetectionio/templates/edit/text-options.html:63
msgid "Keyword example - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:64
msgid "Use groups to extract just that text - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:64
msgid "returns a list of years only"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:65
msgid "Example - match lines containing a keyword"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:68
msgid "One line per regular-expression/string match"
msgstr ""
#~ msgid "Entry"
#~ msgstr ""
#~ msgid "Actions"
#~ msgstr "Condizioni"
#~ msgid "Add a row/rule after"
#~ msgstr ""
#~ msgid "Remove this row/rule"
#~ msgstr ""
#~ msgid "Verify this rule against current snapshot"
#~ msgstr ""
#~ msgid "Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled."
#~ msgstr ""
#~ msgid "Alternatively try our"
#~ msgstr ""
#~ msgid "very affordable subscription based service which has all this setup for you"
#~ msgstr ""
#~ msgid "You may need to"
#~ msgstr ""
#~ msgid "Enable playwright environment variable"
#~ msgstr ""
#~ msgid "and uncomment the"
#~ msgstr ""
#~ msgid "in the"
#~ msgstr "Silenzia"
#~ msgid "file"
#~ msgstr "Titolo"
#~ msgid "Set a hourly/week day schedule"
#~ msgstr ""
#~ msgid "Schedule time limits"
#~ msgstr "Tempo di ricontrollo (minuti)"
#~ msgid "Business hours"
#~ msgstr ""
#~ msgid "Weekends"
#~ msgstr "Settimane"
#~ msgid "Reset"
#~ msgstr "Richiesta"
#~ msgid "Warning, one or more of your 'days' has a duration that would extend into the next day."
#~ msgstr ""
#~ msgid "This could have unintended consequences."
#~ msgstr ""
#~ msgid "More help and examples about using the scheduler"
#~ msgstr ""
#~ msgid "Want to use a time schedule?"
#~ msgstr "Usa pianificazione oraria"
#~ msgid "First confirm/save your Time Zone Settings"
#~ msgstr ""
#~ msgid "Triggers a change if this text appears, AND something changed in the document."
#~ msgstr ""
#~ msgid "Triggered text"
#~ msgstr "Ignora testo"
#~ msgid "Ignored for calculating changes, but still shown."
#~ msgstr ""
#~ msgid "Ignored text"
#~ msgstr "Ignora testo"
#~ msgid "No change-detection will occur because this text exists."
#~ msgstr "Blocca rilevamento modifiche quando il testo corrisponde"
#~ msgid "Blocked text"
#~ msgstr "Ignora testo"
#~ msgid "Watch List"
#~ msgstr "Lista Monitoraggi"
#~ msgid "Watches"
#~ msgstr "Monitoraggi"
#~ msgid "Queue Status"
#~ msgstr ""
#~ msgid "Queue"
#~ msgstr "In coda"
#~ msgid "Sitemap Crawler"
#~ msgstr ""
#~ msgid "Sitemap"
#~ msgstr ""
#~ msgid "Search"
#~ msgstr "Ricerca in corso"
#~ msgid "URL or Title"
#~ msgstr ""
#~ msgid "in"
#~ msgstr "Info"
#~ msgid "Enter search term..."
#~ msgstr ""

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-01-02 16:07+0100\n"
"POT-Creation-Date: 2026-01-03 14:31+0100\n"
"PO-Revision-Date: 2026-01-02 11:40+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: ko\n"
@@ -19,31 +19,35 @@ msgstr ""
"Generated-By: Babel 2.17.0\n"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:241
#: changedetectionio/flask_app.py:213 changedetectionio/flask_app.py:225
#: changedetectionio/flask_app.py:246
#: changedetectionio/flask_app.py:214 changedetectionio/flask_app.py:226
#: changedetectionio/flask_app.py:247
#: changedetectionio/realtime/socket_server.py:171
msgid "Not yet"
msgstr "아직 아님"
#: changedetectionio/flask_app.py:468
msgid "You must be logged in, please log in."
msgstr ""
#: changedetectionio/flask_app.py:495
#: changedetectionio/flask_app.py:534
msgid "Already logged in"
msgstr ""
#: changedetectionio/flask_app.py:522
#: changedetectionio/flask_app.py:536
msgid "You must be logged in, please log in."
msgstr ""
#: changedetectionio/flask_app.py:551
#, fuzzy
msgid "Incorrect password"
msgstr "비밀번호"
#: changedetectionio/forms.py:63 changedetectionio/forms.py:243
msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified."
msgid ""
"At least one time interval (weeks, days, hours, minutes, or seconds) must"
" be specified."
msgstr ""
#: changedetectionio/forms.py:64
msgid "At least one time interval (weeks, days, hours, minutes, or seconds) must be specified when not using global settings."
msgid ""
"At least one time interval (weeks, days, hours, minutes, or seconds) must"
" be specified when not using global settings."
msgstr ""
#: changedetectionio/forms.py:164
@@ -376,8 +380,9 @@ msgid "Muted"
msgstr "무음"
#: changedetectionio/forms.py:832
#, fuzzy
msgid "On"
msgstr "켜짐"
msgstr "없음"
#: changedetectionio/forms.py:833
msgid "Attach screenshot to notification (where possible)"
@@ -612,6 +617,8 @@ msgid "Backups were deleted."
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html:6
#: changedetectionio/templates/base.html:282
#: changedetectionio/templates/base.html:290
msgid "Backups"
msgstr "백업"
@@ -620,7 +627,9 @@ msgid "A backup is running!"
msgstr "백업이 실행 중입니다!"
#: changedetectionio/blueprint/backups/templates/overview.html:13
msgid "Here you can download and request a new backup, when a backup is completed you will see it listed below."
msgid ""
"Here you can download and request a new backup, when a backup is "
"completed you will see it listed below."
msgstr ""
#: changedetectionio/blueprint/backups/templates/overview.html:19
@@ -640,7 +649,9 @@ msgid "Remove backups"
msgstr "백업 삭제"
#: changedetectionio/blueprint/imports/importer.py:45
msgid "Importing 5,000 of the first URLs from your list, the rest can be imported again."
msgid ""
"Importing 5,000 of the first URLs from your list, the rest can be "
"imported again."
msgstr ""
#: changedetectionio/blueprint/imports/importer.py:78
@@ -675,7 +686,9 @@ msgstr ""
#: changedetectionio/blueprint/imports/importer.py:214
#: changedetectionio/blueprint/imports/importer.py:297
#, python-brace-format
msgid "Error processing row number {}, check all cell data types are correct, row was skipped."
msgid ""
"Error processing row number {}, check all cell data types are correct, "
"row was skipped."
msgstr ""
#: changedetectionio/blueprint/imports/importer.py:218
@@ -701,7 +714,9 @@ msgid ".XLSX & Wachete"
msgstr ".XLSX 및 와체테"
#: changedetectionio/blueprint/imports/templates/import.html:20
msgid "Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):"
msgid ""
"Enter one URL per line, and optionally add tags for each URL after a "
"space, delineated by comma (,):"
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:22
@@ -713,7 +728,9 @@ msgid "URLs which do not pass validation will stay in the textarea."
msgstr "유효성 검사를 통과하지 못한 URL은 텍스트 영역에 유지됩니다."
#: changedetectionio/blueprint/imports/templates/import.html:44
msgid "Copy and Paste your Distill.io watch 'export' file, this should be a JSON file."
msgid ""
"Copy and Paste your Distill.io watch 'export' file, this should be a JSON"
" file."
msgstr ""
#: changedetectionio/blueprint/imports/templates/import.html:45
@@ -1009,7 +1026,9 @@ msgid "Watch group / tag"
msgstr "감시 그룹/태그"
#: changedetectionio/blueprint/tags/templates/groups-overview.html:21
msgid "Groups allows you to manage filters and notifications for multiple watches under a single organisational tag."
msgid ""
"Groups allows you to manage filters and notifications for multiple "
"watches under a single organisational tag."
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:31
@@ -1035,7 +1054,9 @@ msgstr "그룹을 삭제하시겠습니까?"
#: changedetectionio/blueprint/tags/templates/groups-overview.html:59
#, python-format
msgid "<p>Are you sure you want to delete group <strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>"
msgid ""
"<p>Are you sure you want to delete group "
"<strong>%(title)s</strong>?</p><p>This action cannot be undone.</p>"
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:60
@@ -1055,7 +1076,10 @@ msgstr "그룹을 연결 해제하시겠습니까?"
#: changedetectionio/blueprint/tags/templates/groups-overview.html:67
#, python-format
msgid "<p>Are you sure you want to unlink all watches from group <strong>%(title)s</strong>?</p><p>The tag will be kept but watches will be removed from it.</p>"
msgid ""
"<p>Are you sure you want to unlink all watches from group "
"<strong>%(title)s</strong>?</p><p>The tag will be kept but watches will "
"be removed from it.</p>"
msgstr ""
#: changedetectionio/blueprint/tags/templates/groups-overview.html:68
@@ -1176,7 +1200,9 @@ msgstr ""
#: changedetectionio/blueprint/ui/__init__.py:330
#, python-brace-format
msgid "Could not share, something went wrong while communicating with the share server - {}"
msgid ""
"Could not share, something went wrong while communicating with the share "
"server - {}"
msgstr ""
#: changedetectionio/blueprint/ui/diff.py:93
@@ -1189,7 +1215,9 @@ msgid "No history found for the specified link, bad link?"
msgstr ""
#: changedetectionio/blueprint/ui/diff.py:98
msgid "Not enough history (2 snapshots required) to show difference page for this watch."
msgid ""
"Not enough history (2 snapshots required) to show difference page for "
"this watch."
msgstr ""
#: changedetectionio/blueprint/ui/edit.py:35
@@ -1238,7 +1266,9 @@ msgid "Watch added."
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:12
msgid "This will remove version history (snapshots) for ALL watches, but keep your list of URLs!"
msgid ""
"This will remove version history (snapshots) for ALL watches, but keep "
"your list of URLs!"
msgstr ""
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:13
@@ -1274,7 +1304,8 @@ msgid "Clear History!"
msgstr "기록 지우기"
#: changedetectionio/blueprint/ui/templates/clear_all_history.html:39
#: changedetectionio/templates/base.html:274
#: changedetectionio/templates/base.html:379
#: changedetectionio/templates/base.html:399
msgid "Cancel"
msgstr "취소"
@@ -1420,7 +1451,9 @@ msgstr "공유하거나 무시 목록에 추가할 텍스트를 강조 표시합
#: changedetectionio/blueprint/ui/templates/diff.html:144
#: changedetectionio/blueprint/ui/templates/preview.html:80
msgid "For now, Differences are performed on text, not graphically, only the latest screenshot is available."
msgid ""
"For now, Differences are performed on text, not graphically, only the "
"latest screenshot is available."
msgstr ""
#: changedetectionio/blueprint/ui/templates/diff.html:149
@@ -1482,7 +1515,9 @@ msgid "Organisational tag/group name used in the main listing page"
msgstr "기본 목록 페이지에 사용되는 조직 태그/그룹 이름"
#: changedetectionio/blueprint/ui/templates/edit.html:85
msgid "Automatically uses the page title if found, you can also use your own title/description here"
msgid ""
"Automatically uses the page title if found, you can also use your own "
"title/description here"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:95
@@ -1490,7 +1525,10 @@ msgid "The interval/amount of time between each check."
msgstr "각 확인 사이의 간격/시간입니다."
#: changedetectionio/blueprint/ui/templates/edit.html:110
msgid "Sends a notification when the filter can no longer be seen on the page, good for knowing when the page changed and your filter will not work anymore."
msgid ""
"Sends a notification when the filter can no longer be seen on the page, "
"good for knowing when the page changed and your filter will not work "
"anymore."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:123
@@ -1502,7 +1540,9 @@ msgid "Basic"
msgstr "기초적인"
#: changedetectionio/blueprint/ui/templates/edit.html:123
msgid "method (default) where your watched site doesn't need Javascript to render."
msgid ""
"method (default) where your watched site doesn't need Javascript to "
"render."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:124
@@ -1514,7 +1554,9 @@ msgid "Chrome/Javascript"
msgstr "크롬/자바스크립트"
#: changedetectionio/blueprint/ui/templates/edit.html:124
msgid "method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'."
msgid ""
"method requires a network connection to a running WebDriver+Chrome "
"server, set by the ENV var 'WEBDRIVER_URL'."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:125
@@ -1530,7 +1572,9 @@ msgid "Choose a proxy for this watch"
msgstr "이 시계에 대한 RSS 피드"
#: changedetectionio/blueprint/ui/templates/edit.html:143
msgid "If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here."
msgid ""
"If you're having trouble waiting for the page to be fully rendered (text "
"missing etc), try increasing the 'wait' time here."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:145
@@ -1551,7 +1595,9 @@ msgid "Show advanced options"
msgstr "고급 옵션 표시"
#: changedetectionio/blueprint/ui/templates/edit.html:157
msgid "Run this code before performing change detection, handy for filling in fields and other actions"
msgid ""
"Run this code before performing change detection, handy for filling in "
"fields and other actions"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:158
@@ -1608,7 +1654,9 @@ msgid "Visual Selector data is not ready, watch needs to be checked atleast once
msgstr "시각적 선택기 데이터가 준비되지 않았습니다. 시계를 한 번 이상 확인해야 합니다."
#: changedetectionio/blueprint/ui/templates/edit.html:253
msgid "Sorry, this functionality only works with fetchers that support interactive Javascript (so far only Playwright based fetchers)"
msgid ""
"Sorry, this functionality only works with fetchers that support "
"interactive Javascript (so far only Playwright based fetchers)"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:254
@@ -1626,7 +1674,9 @@ msgid "to one that supports interactive Javascript."
msgstr "대화형 Javascript를 지원하는 것입니다."
#: changedetectionio/blueprint/ui/templates/edit.html:297
msgid "Use the verify (✓) button to test if a condition passes against the current snapshot."
msgid ""
"Use the verify (✓) button to test if a condition passes against the "
"current snapshot."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:298
@@ -1654,7 +1704,9 @@ msgid "Limit trigger/ignore/block/extract to;"
msgstr "트리거/무시/차단/추출을 다음으로 제한합니다."
#: changedetectionio/blueprint/ui/templates/edit.html:326
msgid "Note: Depending on the length and similarity of the text on each line, the algorithm may consider an"
msgid ""
"Note: Depending on the length and similarity of the text on each line, "
"the algorithm may consider an"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:326
@@ -1695,11 +1747,16 @@ msgid "Only trigger when unique lines appear"
msgstr "고유한 줄이 나타날 때만 트리거됩니다."
#: changedetectionio/blueprint/ui/templates/edit.html:332
msgid "Good for websites that just move the content around, and you want to know when NEW content is added, compares new lines against all history for this watch."
msgid ""
"Good for websites that just move the content around, and you want to know"
" when NEW content is added, compares new lines against all history for "
"this watch."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:340
msgid "Helps reduce changes detected caused by sites shuffling lines around, combine with"
msgid ""
"Helps reduce changes detected caused by sites shuffling lines around, "
"combine with"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:340
@@ -1729,7 +1786,9 @@ msgid "text"
msgstr "텍스트"
#: changedetectionio/blueprint/ui/templates/edit.html:386
msgid "elements that will be used for the change detection. It automatically fills-in the filters in the \"CSS/JSONPath/JQ/XPath Filters\" box of the"
msgid ""
"elements that will be used for the change detection. It automatically "
"fills-in the filters in the \"CSS/JSONPath/JQ/XPath Filters\" box of the"
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:386
@@ -1769,7 +1828,9 @@ msgid "Currently:"
msgstr "현재:"
#: changedetectionio/blueprint/ui/templates/edit.html:423
msgid "Sorry, this functionality only works with fetchers that support Javascript and screenshots (such as playwright etc)."
msgid ""
"Sorry, this functionality only works with fetchers that support "
"Javascript and screenshots (such as playwright etc)."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:424
@@ -1825,7 +1886,9 @@ msgid "Are you sure you want to clear all history for:"
msgstr "정말로 다음의 기록을 모두 지우시겠습니까?"
#: changedetectionio/blueprint/ui/templates/edit.html:496
msgid "This will remove all snapshots and previous versions. This action cannot be undone."
msgid ""
"This will remove all snapshots and previous versions. This action cannot "
"be undone."
msgstr ""
#: changedetectionio/blueprint/ui/templates/edit.html:497
@@ -1849,7 +1912,9 @@ msgid "Current erroring screenshot from most recent request"
msgstr "가장 최근 요청의 현재 오류 스크린샷"
#: changedetectionio/blueprint/ui/templates/preview.html:91
msgid "Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc ) that supports screenshots."
msgid ""
"Screenshot requires a Content Fetcher ( Sockpuppetbrowser, selenium, etc "
") that supports screenshots."
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:31
@@ -1918,7 +1983,9 @@ msgid "Clear Histories"
msgstr "기록 지우기"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:66
msgid "<p>Are you sure you want to clear history for the selected items?</p><p>This action cannot be undone.</p>"
msgid ""
"<p>Are you sure you want to clear history for the selected "
"items?</p><p>This action cannot be undone.</p>"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:67
@@ -1934,7 +2001,9 @@ msgid "Delete Watches?"
msgstr "시계를 삭제하시겠습니까?"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:72
msgid "<p>Are you sure you want to delete the selected watches?</strong></p><p>This action cannot be undone.</p>"
msgid ""
"<p>Are you sure you want to delete the selected "
"watches?</strong></p><p>This action cannot be undone.</p>"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:78
@@ -1967,7 +2036,7 @@ msgid "Changed"
msgstr "변경됨"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
msgid "No web page change detection watches configured, please add a URL in the box above, or"
msgid "No website watches configured, please add a URL in the box above, or"
msgstr "구성된 웹사이트 시계가 없습니다. 위 상자에 URL을 추가하세요. 또는"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
@@ -1995,7 +2064,7 @@ msgid "No information"
msgstr "정보 없음"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:234
#: changedetectionio/templates/base.html:248
#: changedetectionio/templates/base.html:353
msgid "Checking now"
msgstr "지금 확인 중"
@@ -2181,226 +2250,267 @@ msgstr "웹페이지 텍스트/HTML, JSON 및 PDF 변경"
msgid "Detects all text changes where possible"
msgstr "가능한 경우 모든 텍스트 변경 사항을 감지합니다."
#: changedetectionio/templates/base.html:77
#: changedetectionio/templates/_helpers.html:25
msgid "Entry"
msgstr ""
#: changedetectionio/templates/_helpers.html:153
#, fuzzy
msgid "Actions"
msgstr "정황"
#: changedetectionio/templates/_helpers.html:172
msgid "Add a row/rule after"
msgstr ""
#: changedetectionio/templates/_helpers.html:173
msgid "Remove this row/rule"
msgstr ""
#: changedetectionio/templates/_helpers.html:174
msgid "Verify this rule against current snapshot"
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid ""
"Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but "
"Chrome based fetching is not enabled."
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid "Alternatively try our"
msgstr ""
#: changedetectionio/templates/_helpers.html:184
msgid ""
"very affordable subscription based service which has all this setup for "
"you"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
#, fuzzy
msgid "You may need to"
msgstr "당신은"
#: changedetectionio/templates/_helpers.html:185
msgid "Enable playwright environment variable"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
msgid "and uncomment the"
msgstr ""
#: changedetectionio/templates/_helpers.html:185
#, fuzzy
msgid "in the"
msgstr "그만큼"
#: changedetectionio/templates/_helpers.html:185
#, fuzzy
msgid "file"
msgstr "제목"
#: changedetectionio/templates/_helpers.html:240
msgid "Set a hourly/week day schedule"
msgstr ""
#: changedetectionio/templates/_helpers.html:247
#, fuzzy
msgid "Schedule time limits"
msgstr "재확인 시간(분)"
#: changedetectionio/templates/_helpers.html:248
msgid "Business hours"
msgstr ""
#: changedetectionio/templates/_helpers.html:249
#, fuzzy
msgid "Weekends"
msgstr "주"
#: changedetectionio/templates/_helpers.html:250
#, fuzzy
msgid "Reset"
msgstr "요구"
#: changedetectionio/templates/_helpers.html:259
msgid ""
"Warning, one or more of your 'days' has a duration that would extend into"
" the next day."
msgstr ""
#: changedetectionio/templates/_helpers.html:260
msgid "This could have unintended consequences."
msgstr ""
#: changedetectionio/templates/_helpers.html:270
#, fuzzy
msgid "More help and examples about using the scheduler"
msgstr "여기에 더 많은 도움말과 예시가 있습니다."
#: changedetectionio/templates/_helpers.html:275
#, fuzzy
msgid "Want to use a time schedule?"
msgstr "시간 스케줄러 사용"
#: changedetectionio/templates/_helpers.html:275
msgid "First confirm/save your Time Zone Settings"
msgstr ""
#: changedetectionio/templates/_helpers.html:284
msgid ""
"Triggers a change if this text appears, AND something changed in the "
"document."
msgstr ""
#: changedetectionio/templates/_helpers.html:284
#, fuzzy
msgid "Triggered text"
msgstr "오류 텍스트"
#: changedetectionio/templates/_helpers.html:285
msgid "Ignored for calculating changes, but still shown."
msgstr ""
#: changedetectionio/templates/_helpers.html:285
#, fuzzy
msgid "Ignored text"
msgstr "오류 텍스트"
#: changedetectionio/templates/_helpers.html:286
#, fuzzy
msgid "No change-detection will occur because this text exists."
msgstr "텍스트가 일치하는 동안 변경 감지 차단"
#: changedetectionio/templates/_helpers.html:286
#, fuzzy
msgid "Blocked text"
msgstr "오류 텍스트"
#: changedetectionio/templates/base.html:78
#: changedetectionio/templates/base.html:168
msgid "GROUPS"
msgstr "여러 떼"
#: changedetectionio/templates/base.html:80
#: changedetectionio/templates/base.html:81
#: changedetectionio/templates/base.html:169
msgid "SETTINGS"
msgstr "설정"
#: changedetectionio/templates/base.html:83
#: changedetectionio/templates/base.html:84
#: changedetectionio/templates/base.html:170
msgid "IMPORT"
msgstr "가져오기"
#: changedetectionio/templates/base.html:86
#: changedetectionio/templates/base.html:87
#: changedetectionio/templates/base.html:171
msgid "BACKUPS"
msgstr "백업"
#: changedetectionio/templates/base.html:90
#: changedetectionio/templates/base.html:91
#: changedetectionio/templates/base.html:173
msgid "EDIT"
msgstr "편집하다"
#: changedetectionio/templates/base.html:100
#: changedetectionio/templates/base.html:101
#: changedetectionio/templates/base.html:177
msgid "LOG OUT"
msgstr "로그아웃"
#: changedetectionio/templates/base.html:109
#: changedetectionio/templates/base.html:108
msgid "Search, or Use Alt+S Key"
msgstr "검색 또는 Alt+S 키 사용"
#: changedetectionio/templates/base.html:116
#: changedetectionio/templates/base.html:114
msgid "Toggle Light/Dark Mode"
msgstr "밝은/어두운 모드 전환"
#: changedetectionio/templates/base.html:117
#: changedetectionio/templates/base.html:115
msgid "Toggle light/dark mode"
msgstr "밝은/어두운 모드 전환"
#: changedetectionio/templates/base.html:127
#: changedetectionio/templates/base.html:125
msgid "Change Language"
msgstr "언어 변경"
#: changedetectionio/templates/base.html:128
#: changedetectionio/templates/base.html:126
msgid "Change language"
msgstr "언어 변경"
#: changedetectionio/templates/base.html:249
#: changedetectionio/templates/base.html:253
#, fuzzy
msgid "Watch List"
msgstr "모니터 목록"
#: changedetectionio/templates/base.html:258
#, fuzzy
msgid "Watches"
msgstr "모니터"
#: changedetectionio/templates/base.html:261
msgid "Queue Status"
msgstr ""
#: changedetectionio/templates/base.html:270
#, fuzzy
msgid "Queue"
msgstr "대기 중"
#: changedetectionio/templates/base.html:274
#: changedetectionio/templates/base.html:279
#, fuzzy
msgid "Settings"
msgstr "설정"
#: changedetectionio/templates/base.html:293
msgid "Sitemap Crawler"
msgstr ""
#: changedetectionio/templates/base.html:318
msgid "Sitemap"
msgstr ""
#: changedetectionio/templates/base.html:354
msgid "Real-time updates offline"
msgstr "실시간 업데이트 오프라인"
#: changedetectionio/templates/base.html:259
#: changedetectionio/templates/base.html:364
msgid "Select Language"
msgstr "언어 선택"
#: changedetectionio/templates/base.html:270
msgid "Language support is in beta, please help us improve by opening a PR on GitHub with any updates."
#: changedetectionio/templates/base.html:375
msgid ""
"Language support is in beta, please help us improve by opening a PR on "
"GitHub with any updates."
msgstr ""
#: changedetectionio/templates/login.html:10
#: changedetectionio/templates/base.html:387
#: changedetectionio/templates/base.html:400
#, fuzzy
msgid "Search"
msgstr "수색"
#: changedetectionio/templates/base.html:392
msgid "URL or Title"
msgstr ""
#: changedetectionio/templates/base.html:392
#, fuzzy
msgid "in"
msgstr "추가 정보"
#: changedetectionio/templates/base.html:393
msgid "Enter search term..."
msgstr ""
#: changedetectionio/templates/login.html:11
msgid "Password"
msgstr "비밀번호"
#: changedetectionio/templates/login.html:16
#: changedetectionio/templates/login.html:17
msgid "Login"
msgstr "로그인"
#: changedetectionio/widgets/ternary_boolean.py:18
#: changedetectionio/widgets/ternary_boolean.py:72
msgid "Yes"
msgstr "예"
#: changedetectionio/widgets/ternary_boolean.py:19
#: changedetectionio/widgets/ternary_boolean.py:73
msgid "No"
msgstr "아니오"
#: changedetectionio/widgets/ternary_boolean.py:20
#: changedetectionio/widgets/ternary_boolean.py:74
msgid "Main settings"
msgstr "기본 설정"
#: changedetectionio/templates/_common_fields.html:11
msgid "Show token/placeholders"
msgstr ""
#: changedetectionio/templates/_common_fields.html:18
msgid "Token"
msgstr ""
#: changedetectionio/templates/_common_fields.html:19
#, fuzzy
msgid "Description"
msgstr "덧셈"
#: changedetectionio/templates/_common_fields.html:128
#, fuzzy
msgid "Show advanced help and tips"
msgstr "고급 옵션 표시"
#: changedetectionio/templates/_common_fields.html:138
#, fuzzy
msgid "Send test notification"
msgstr "기본 알림 사용"
#: changedetectionio/templates/_common_fields.html:140
msgid "Add email"
msgstr ""
#: changedetectionio/templates/_common_fields.html:140
msgid "Add an email address"
msgstr ""
#: changedetectionio/templates/_common_fields.html:142
#, fuzzy
msgid "Notification debug logs"
msgstr "알림 디버그 로그"
#: changedetectionio/templates/_common_fields.html:144
#, fuzzy
msgid "Processing.."
msgstr "프로세서"
#: changedetectionio/templates/_common_fields.html:151
#, fuzzy
msgid "Title for all notifications"
msgstr "기본 알림 사용"
#: changedetectionio/templates/_common_fields.html:176
#, fuzzy
msgid "Format for all notifications"
msgstr "기본 알림 사용"
#: changedetectionio/templates/edit/text-options.html:9
msgid "Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive."
msgstr ""
#: changedetectionio/templates/edit/text-options.html:10
msgid "Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:11
msgid "Each line is processed separately (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:12
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:23
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:24
msgid "Each line processed separately, any line matching will be ignored (removed before creating the checksum)"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:25
msgid "Regular Expression support, wrap the entire line in forward slash"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:26
msgid "Changing this will affect the comparison checksum which may trigger an alert"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:43
msgid "Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for waiting for when a product is available again"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:44
msgid "Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:45
msgid "All lines here must not exist (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:58
msgid "Extracts text in the final output (line by line) after other filters using regular expressions or string match:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:60
msgid "Regular expression - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:61
msgid "Don't forget to consider the white-space at the start of a line"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
#, fuzzy
msgid "Use"
msgstr "정지시키다"
#: changedetectionio/templates/edit/text-options.html:62
msgid "type flags (more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
#, fuzzy
msgid "information here"
msgstr "정보 없음"
#: changedetectionio/templates/edit/text-options.html:63
msgid "Keyword example - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:64
msgid "Use groups to extract just that text - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:64
msgid "returns a list of years only"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:65
msgid "Example - match lines containing a keyword"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:68
msgid "One line per regular-expression/string match"
msgstr ""
#~ msgid "Invalid time format. Use HH:MM."
#~ msgstr "시간 형식이 잘못되었습니다. HH:MM을 사용하세요."
@@ -2782,7 +2892,9 @@ msgstr ""
#~ msgid "Visual / Image screenshot change detection"
#~ msgstr "시각적/이미지 스크린샷 변경 감지"
#~ msgid "Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM"
#~ msgid ""
#~ "Compares screenshots using fast OpenCV "
#~ "algorithm, 10-100x faster than SSIM"
#~ msgstr "SSIM보다 10~100배 빠른 빠른 OpenCV 알고리즘을 사용하여 스크린샷을 비교합니다."
#~ msgid "Visual"
@@ -2803,120 +2915,3 @@ msgstr ""
#~ msgid "Detects all text changes where possible"
#~ msgstr "가능한 경우 모든 텍스트 변경 사항을 감지합니다."
#~ msgid "Entry"
#~ msgstr ""
#~ msgid "Actions"
#~ msgstr "정황"
#~ msgid "Add a row/rule after"
#~ msgstr ""
#~ msgid "Remove this row/rule"
#~ msgstr ""
#~ msgid "Verify this rule against current snapshot"
#~ msgstr ""
#~ msgid "Error - This watch needs Chrome (with playwright/sockpuppetbrowser), but Chrome based fetching is not enabled."
#~ msgstr ""
#~ msgid "Alternatively try our"
#~ msgstr ""
#~ msgid "very affordable subscription based service which has all this setup for you"
#~ msgstr ""
#~ msgid "You may need to"
#~ msgstr "당신은"
#~ msgid "Enable playwright environment variable"
#~ msgstr ""
#~ msgid "and uncomment the"
#~ msgstr ""
#~ msgid "in the"
#~ msgstr "그만큼"
#~ msgid "file"
#~ msgstr "제목"
#~ msgid "Set a hourly/week day schedule"
#~ msgstr ""
#~ msgid "Schedule time limits"
#~ msgstr "재확인 시간(분)"
#~ msgid "Business hours"
#~ msgstr ""
#~ msgid "Weekends"
#~ msgstr "주"
#~ msgid "Reset"
#~ msgstr "요구"
#~ msgid "Warning, one or more of your 'days' has a duration that would extend into the next day."
#~ msgstr ""
#~ msgid "This could have unintended consequences."
#~ msgstr ""
#~ msgid "More help and examples about using the scheduler"
#~ msgstr "여기에 더 많은 도움말과 예시가 있습니다."
#~ msgid "Want to use a time schedule?"
#~ msgstr "시간 스케줄러 사용"
#~ msgid "First confirm/save your Time Zone Settings"
#~ msgstr ""
#~ msgid "Triggers a change if this text appears, AND something changed in the document."
#~ msgstr ""
#~ msgid "Triggered text"
#~ msgstr "오류 텍스트"
#~ msgid "Ignored for calculating changes, but still shown."
#~ msgstr ""
#~ msgid "Ignored text"
#~ msgstr "오류 텍스트"
#~ msgid "No change-detection will occur because this text exists."
#~ msgstr "텍스트가 일치하는 동안 변경 감지 차단"
#~ msgid "Blocked text"
#~ msgstr "오류 텍스트"
#~ msgid "Watch List"
#~ msgstr "모니터 목록"
#~ msgid "Watches"
#~ msgstr "모니터"
#~ msgid "Queue Status"
#~ msgstr ""
#~ msgid "Queue"
#~ msgstr "대기 중"
#~ msgid "Sitemap Crawler"
#~ msgstr ""
#~ msgid "Sitemap"
#~ msgstr ""
#~ msgid "Search"
#~ msgstr "수색"
#~ msgid "URL or Title"
#~ msgstr ""
#~ msgid "in"
#~ msgstr "추가 정보"
#~ msgid "Enter search term..."
#~ msgstr ""

View File

@@ -1988,7 +1988,7 @@ msgid "Changed"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
msgid "No web page change detection watches configured, please add a URL in the box above, or"
msgid "No website watches configured, please add a URL in the box above, or"
msgstr ""
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html:130
@@ -2265,150 +2265,3 @@ msgstr ""
msgid "Login"
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py:18
#: changedetectionio/widgets/ternary_boolean.py:72
msgid "Yes"
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py:19
#: changedetectionio/widgets/ternary_boolean.py:73
msgid "No"
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py:20
#: changedetectionio/widgets/ternary_boolean.py:74
msgid "Main settings"
msgstr ""
#: changedetectionio/templates/_common_fields.html:11
msgid "Show token/placeholders"
msgstr ""
#: changedetectionio/templates/_common_fields.html:18
msgid "Token"
msgstr ""
#: changedetectionio/templates/_common_fields.html:19
msgid "Description"
msgstr ""
#: changedetectionio/templates/_common_fields.html:128
msgid "Show advanced help and tips"
msgstr ""
#: changedetectionio/templates/_common_fields.html:138
msgid "Send test notification"
msgstr ""
#: changedetectionio/templates/_common_fields.html:140
msgid "Add email"
msgstr ""
#: changedetectionio/templates/_common_fields.html:140
msgid "Add an email address"
msgstr ""
#: changedetectionio/templates/_common_fields.html:142
msgid "Notification debug logs"
msgstr ""
#: changedetectionio/templates/_common_fields.html:144
msgid "Processing.."
msgstr ""
#: changedetectionio/templates/_common_fields.html:151
msgid "Title for all notifications"
msgstr ""
#: changedetectionio/templates/_common_fields.html:176
msgid "Format for all notifications"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:9
msgid "Text to wait for before triggering a change/notification, all text and regex are tested case-insensitive."
msgstr ""
#: changedetectionio/templates/edit/text-options.html:10
msgid "Trigger text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:11
msgid "Each line is processed separately (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:12
msgid "Note: Wrap in forward slash / to use regex example:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:23
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:24
msgid "Each line processed separately, any line matching will be ignored (removed before creating the checksum)"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:25
msgid "Regular Expression support, wrap the entire line in forward slash"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:26
msgid "Changing this will affect the comparison checksum which may trigger an alert"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:43
msgid "Block change-detection while this text is on the page, all text and regex are tested case-insensitive, good for waiting for when a product is available again"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:44
msgid "Block text is processed from the result-text that comes out of any CSS/JSON Filters for this monitor"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:45
msgid "All lines here must not exist (think of each line as \"OR\")"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:58
msgid "Extracts text in the final output (line by line) after other filters using regular expressions or string match:"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:60
msgid "Regular expression - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:61
msgid "Don't forget to consider the white-space at the start of a line"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
msgid "Use"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
msgid "type flags (more"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:62
msgid "information here"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:63
msgid "Keyword example - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:64
msgid "Use groups to extract just that text - example"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:64
msgid "returns a list of years only"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:65
msgid "Example - match lines containing a keyword"
msgstr ""
#: changedetectionio/templates/edit/text-options.html:68
msgid "One line per regular-expression/string match"
msgstr ""

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
from wtforms import Field
from wtforms import widgets
from markupsafe import Markup
from flask_babel import lazy_gettext as _l
class TernaryNoneBooleanWidget:
"""
@@ -14,9 +14,9 @@ class TernaryNoneBooleanWidget:
boolean_mode = getattr(field, 'boolean_mode', False)
# Get custom text or use defaults
yes_text = getattr(field, 'yes_text', _l('Yes'))
no_text = getattr(field, 'no_text', _l('No'))
none_text = getattr(field, 'none_text', _l('Main settings'))
yes_text = getattr(field, 'yes_text', 'Yes')
no_text = getattr(field, 'no_text', 'No')
none_text = getattr(field, 'none_text', 'Main settings')
# True option
checked_true = ' checked' if field.data is True else ''
@@ -63,14 +63,14 @@ class TernaryNoneBooleanField(Field):
"""
widget = TernaryNoneBooleanWidget()
def __init__(self, label=None, validators=None, false_values=None, boolean_mode=False,
yes_text=None, no_text=None, none_text=None, **kwargs):
def __init__(self, label=None, validators=None, false_values=None, boolean_mode=False,
yes_text="Yes", no_text="No", none_text="Main settings", **kwargs):
super(TernaryNoneBooleanField, self).__init__(label, validators, **kwargs)
self.boolean_mode = boolean_mode
self.yes_text = yes_text if yes_text is not None else _l('Yes')
self.no_text = no_text if no_text is not None else _l('No')
self.none_text = none_text if none_text is not None else _l('Main settings')
self.yes_text = yes_text
self.no_text = no_text
self.none_text = none_text
if false_values is None:
self.false_values = {'false', ''}

View File

@@ -2,18 +2,19 @@
Worker management module for changedetection.io
Handles asynchronous workers for dynamic worker scaling.
Each worker runs in its own thread with its own event loop for isolation.
Sync worker support has been removed in favor of async-only architecture.
"""
import asyncio
import os
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from loguru import logger
# Global worker state - each worker has its own thread and event loop
worker_threads = [] # List of WorkerThread objects
# Global worker state
running_async_tasks = []
async_loop = None
async_loop_thread = None
# Track currently processing UUIDs for async workers - maps {uuid: worker_id}
currently_processing_uuids = {}
@@ -21,118 +22,89 @@ currently_processing_uuids = {}
# Configuration - async workers only
USE_ASYNC_WORKERS = True
# Custom ThreadPoolExecutor for queue operations with named threads
# Scale executor threads with FETCH_WORKERS to avoid bottleneck at high concurrency
_max_executor_workers = max(50, int(os.getenv("FETCH_WORKERS", "10")))
queue_executor = ThreadPoolExecutor(
max_workers=_max_executor_workers,
thread_name_prefix="QueueGetter-"
)
class WorkerThread:
"""Container for a worker thread with its own event loop"""
def __init__(self, worker_id, update_q, notification_q, app, datastore):
self.worker_id = worker_id
self.update_q = update_q
self.notification_q = notification_q
self.app = app
self.datastore = datastore
self.thread = None
self.loop = None
self.running = False
def run(self):
"""Run the worker in its own event loop"""
try:
# Create a new event loop for this thread
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.running = True
# Run the worker coroutine
self.loop.run_until_complete(
start_single_async_worker(
self.worker_id,
self.update_q,
self.notification_q,
self.app,
self.datastore,
queue_executor
)
)
except asyncio.CancelledError:
# Normal shutdown - worker was cancelled
import os
in_pytest = "pytest" in os.sys.modules or "PYTEST_CURRENT_TEST" in os.environ
if not in_pytest:
logger.info(f"Worker {self.worker_id} shutting down gracefully")
except RuntimeError as e:
# Ignore expected shutdown errors
if "Event loop stopped" not in str(e) and "Event loop is closed" not in str(e):
logger.error(f"Worker {self.worker_id} runtime error: {e}")
except Exception as e:
logger.error(f"Worker {self.worker_id} thread error: {e}")
finally:
# Clean up
if self.loop and not self.loop.is_closed():
self.loop.close()
self.running = False
self.loop = None
def start(self):
"""Start the worker thread"""
self.thread = threading.Thread(
target=self.run,
daemon=True,
name=f"PageFetchAsyncUpdateWorker-{self.worker_id}"
)
self.thread.start()
def stop(self):
"""Stop the worker thread"""
if self.loop and self.running:
try:
# Signal the loop to stop
self.loop.call_soon_threadsafe(self.loop.stop)
except RuntimeError:
pass
if self.thread and self.thread.is_alive():
self.thread.join(timeout=2.0)
def start_async_event_loop():
"""Start a dedicated event loop for async workers in a separate thread"""
global async_loop
logger.info("Starting async event loop for workers")
try:
# Create a new event loop for this thread
async_loop = asyncio.new_event_loop()
# Set it as the event loop for this thread
asyncio.set_event_loop(async_loop)
logger.debug(f"Event loop created and set: {async_loop}")
# Run the event loop forever
async_loop.run_forever()
except Exception as e:
logger.error(f"Async event loop error: {e}")
finally:
# Clean up
if async_loop and not async_loop.is_closed():
async_loop.close()
async_loop = None
logger.info("Async event loop stopped")
def start_async_workers(n_workers, update_q, notification_q, app, datastore):
"""Start async workers, each with its own thread and event loop for isolation"""
global worker_threads, currently_processing_uuids
# Clear any stale state
"""Start the async worker management system"""
global async_loop_thread, async_loop, running_async_tasks, currently_processing_uuids
# Clear any stale UUID tracking state
currently_processing_uuids.clear()
# Start each worker in its own thread with its own event loop
logger.info(f"Starting {n_workers} async workers (isolated threads)")
# Start the event loop in a separate thread
async_loop_thread = threading.Thread(target=start_async_event_loop, daemon=True)
async_loop_thread.start()
# Wait for the loop to be available (with timeout for safety)
max_wait_time = 5.0
wait_start = time.time()
while async_loop is None and (time.time() - wait_start) < max_wait_time:
time.sleep(0.1)
if async_loop is None:
logger.error("Failed to start async event loop within timeout")
return
# Additional brief wait to ensure loop is running
time.sleep(0.2)
# Start async workers
logger.info(f"Starting {n_workers} async workers")
for i in range(n_workers):
try:
worker = WorkerThread(i, update_q, notification_q, app, datastore)
worker.start()
worker_threads.append(worker)
# No sleep needed - threads start independently and asynchronously
except Exception as e:
# Use a factory function to create named worker coroutines
def create_named_worker(worker_id):
async def named_worker():
task = asyncio.current_task()
if task:
task.set_name(f"async-worker-{worker_id}")
return await start_single_async_worker(worker_id, update_q, notification_q, app, datastore)
return named_worker()
task_future = asyncio.run_coroutine_threadsafe(create_named_worker(i), async_loop)
running_async_tasks.append(task_future)
except RuntimeError as e:
logger.error(f"Failed to start async worker {i}: {e}")
continue
async def start_single_async_worker(worker_id, update_q, notification_q, app, datastore, executor=None):
async def start_single_async_worker(worker_id, update_q, notification_q, app, datastore):
"""Start a single async worker with auto-restart capability"""
from changedetectionio.async_update_worker import async_update_worker
# Check if we're in pytest environment - if so, be more gentle with logging
import os
in_pytest = "pytest" in os.sys.modules or "PYTEST_CURRENT_TEST" in os.environ
while not app.config.exit.is_set():
try:
await async_update_worker(worker_id, update_q, notification_q, app, datastore, executor)
if not in_pytest:
logger.info(f"Starting async worker {worker_id}")
await async_update_worker(worker_id, update_q, notification_q, app, datastore)
# If we reach here, worker exited cleanly
if not in_pytest:
logger.info(f"Async worker {worker_id} exited cleanly")
@@ -159,38 +131,39 @@ def start_workers(n_workers, update_q, notification_q, app, datastore):
def add_worker(update_q, notification_q, app, datastore):
"""Add a new async worker (for dynamic scaling)"""
global worker_threads
worker_id = len(worker_threads)
logger.info(f"Adding async worker {worker_id}")
try:
worker = WorkerThread(worker_id, update_q, notification_q, app, datastore)
worker.start()
worker_threads.append(worker)
return True
except Exception as e:
logger.error(f"Failed to add worker {worker_id}: {e}")
global running_async_tasks
if not async_loop:
logger.error("Async loop not running, cannot add worker")
return False
worker_id = len(running_async_tasks)
logger.info(f"Adding async worker {worker_id}")
task_future = asyncio.run_coroutine_threadsafe(
start_single_async_worker(worker_id, update_q, notification_q, app, datastore), async_loop
)
running_async_tasks.append(task_future)
return True
def remove_worker():
"""Remove an async worker (for dynamic scaling)"""
global worker_threads
if not worker_threads:
global running_async_tasks
if not running_async_tasks:
return False
# Stop the last worker
worker = worker_threads.pop()
worker.stop()
logger.info(f"Removed async worker, {len(worker_threads)} workers remaining")
# Cancel the last worker
task_future = running_async_tasks.pop()
task_future.cancel()
logger.info(f"Removed async worker, {len(running_async_tasks)} workers remaining")
return True
def get_worker_count():
"""Get current number of async workers"""
return len(worker_threads)
return len(running_async_tasks)
def get_running_uuids():
@@ -276,21 +249,38 @@ def queue_item_async_safe(update_q, item, silent=False):
def shutdown_workers():
"""Shutdown all async workers fast and aggressively"""
global worker_threads
global async_loop, async_loop_thread, running_async_tasks
# Check if we're in pytest environment - if so, be more gentle with logging
import os
in_pytest = "pytest" in os.sys.modules or "PYTEST_CURRENT_TEST" in os.environ
if not in_pytest:
logger.info("Fast shutdown of async workers initiated...")
# Stop all worker threads
for worker in worker_threads:
worker.stop()
worker_threads.clear()
# Cancel all async tasks immediately
for task_future in running_async_tasks:
if not task_future.done():
task_future.cancel()
# Stop the async event loop immediately
if async_loop and not async_loop.is_closed():
try:
async_loop.call_soon_threadsafe(async_loop.stop)
except RuntimeError:
# Loop might already be stopped
pass
running_async_tasks.clear()
async_loop = None
# Give async thread minimal time to finish, then continue
if async_loop_thread and async_loop_thread.is_alive():
async_loop_thread.join(timeout=1.0) # Only 1 second timeout
if async_loop_thread.is_alive() and not in_pytest:
logger.info("Async thread still running after timeout - continuing with shutdown")
async_loop_thread = None
if not in_pytest:
logger.info("Async workers fast shutdown complete")
@@ -300,57 +290,69 @@ def shutdown_workers():
def adjust_async_worker_count(new_count, update_q=None, notification_q=None, app=None, datastore=None):
"""
Dynamically adjust the number of async workers.
Args:
new_count: Target number of workers
update_q, notification_q, app, datastore: Required for adding new workers
Returns:
dict: Status of the adjustment operation
"""
global worker_threads
global running_async_tasks
current_count = get_worker_count()
if new_count == current_count:
return {
'status': 'no_change',
'message': f'Worker count already at {current_count}',
'current_count': current_count
}
if new_count > current_count:
# Add workers
workers_to_add = new_count - current_count
logger.info(f"Adding {workers_to_add} async workers (from {current_count} to {new_count})")
if not all([update_q, notification_q, app, datastore]):
return {
'status': 'error',
'message': 'Missing required parameters to add workers',
'current_count': current_count
}
for i in range(workers_to_add):
add_worker(update_q, notification_q, app, datastore)
worker_id = len(running_async_tasks)
task_future = asyncio.run_coroutine_threadsafe(
start_single_async_worker(worker_id, update_q, notification_q, app, datastore),
async_loop
)
running_async_tasks.append(task_future)
return {
'status': 'success',
'message': f'Added {workers_to_add} workers',
'previous_count': current_count,
'current_count': len(worker_threads)
'current_count': new_count
}
else:
# Remove workers
workers_to_remove = current_count - new_count
logger.info(f"Removing {workers_to_remove} async workers (from {current_count} to {new_count})")
removed_count = 0
for _ in range(workers_to_remove):
if remove_worker():
if running_async_tasks:
task_future = running_async_tasks.pop()
task_future.cancel()
# Wait for the task to actually stop
try:
task_future.result(timeout=5) # 5 second timeout
except Exception:
pass # Task was cancelled, which is expected
removed_count += 1
return {
'status': 'success',
'message': f'Removed {removed_count} workers',
@@ -365,58 +367,72 @@ def get_worker_status():
'worker_type': 'async',
'worker_count': get_worker_count(),
'running_uuids': get_running_uuids(),
'active_threads': sum(1 for w in worker_threads if w.thread and w.thread.is_alive()),
'async_loop_running': async_loop is not None,
}
def check_worker_health(expected_count, update_q=None, notification_q=None, app=None, datastore=None):
"""
Check if the expected number of async workers are running and restart any missing ones.
Args:
expected_count: Expected number of workers
update_q, notification_q, app, datastore: Required for restarting workers
Returns:
dict: Health check results
"""
global worker_threads
global running_async_tasks
current_count = get_worker_count()
# Check which workers are actually alive
alive_count = sum(1 for w in worker_threads if w.thread and w.thread.is_alive())
if alive_count == expected_count:
if current_count == expected_count:
return {
'status': 'healthy',
'expected_count': expected_count,
'actual_count': alive_count,
'actual_count': current_count,
'message': f'All {expected_count} async workers running'
}
# Find dead workers
# Check for crashed async workers
dead_workers = []
for i, worker in enumerate(worker_threads[:]):
if not worker.thread or not worker.thread.is_alive():
dead_workers.append(i)
logger.warning(f"Async worker {worker.worker_id} thread is dead")
alive_count = 0
for i, task_future in enumerate(running_async_tasks[:]):
if task_future.done():
try:
result = task_future.result()
dead_workers.append(i)
logger.warning(f"Async worker {i} completed unexpectedly")
except Exception as e:
dead_workers.append(i)
logger.error(f"Async worker {i} crashed: {e}")
else:
alive_count += 1
# Remove dead workers from tracking
for i in reversed(dead_workers):
if i < len(worker_threads):
worker_threads.pop(i)
if i < len(running_async_tasks):
running_async_tasks.pop(i)
missing_workers = expected_count - alive_count
restarted_count = 0
if missing_workers > 0 and all([update_q, notification_q, app, datastore]):
logger.info(f"Restarting {missing_workers} crashed async workers")
for i in range(missing_workers):
if add_worker(update_q, notification_q, app, datastore):
worker_id = alive_count + i
try:
task_future = asyncio.run_coroutine_threadsafe(
start_single_async_worker(worker_id, update_q, notification_q, app, datastore),
async_loop
)
running_async_tasks.append(task_future)
restarted_count += 1
except Exception as e:
logger.error(f"Failed to restart worker {worker_id}: {e}")
return {
'status': 'repaired' if restarted_count > 0 else 'degraded',
'expected_count': expected_count,

View File

@@ -42,7 +42,7 @@ orjson~=3.11
# jq not available on Windows so must be installed manually
# Notification library
apprise==1.9.6
apprise==1.9.5
diff_match_patch
@@ -70,7 +70,7 @@ lxml >=4.8.0,!=5.2.0,!=5.2.1,<7
# XPath 2.0-3.1 support - 4.2.0 had issues, 4.1.5 stable
# Consider updating to latest stable version periodically
elementpath==5.1.0
elementpath==5.0.4
# For fast image comparison in screenshot change detection
# opencv-python-headless is OPTIONAL (excluded from requirements.txt)
@@ -91,7 +91,7 @@ jq~=1.3; python_version >= "3.8" and sys_platform == "linux"
# playwright is installed at Dockerfile build time because it's not available on all platforms
pyppeteer-ng==2.0.0rc11
pyppeteer-ng==2.0.0rc10
pyppeteerstealth>=0.0.4
# Include pytest, so if theres a support issue we can ask them to run these tests on their setup
@@ -100,7 +100,7 @@ pytest-flask ~=1.3
pytest-mock ~=3.15
# Anything 4.0 and up but not 5.0
jsonschema ~= 4.26
jsonschema ~= 4.25
# OpenAPI validation support
openapi-core[flask] >= 0.19.0