From 5fafe222b4bee43f22189b50ad3bfef18c9e62ad Mon Sep 17 00:00:00 2001 From: DorkSoul Date: Sat, 9 May 2026 11:03:00 +0100 Subject: [PATCH 1/8] fix scheduler race conditions and stale next_check on auto-resume - Check download_queue after poll loop exits (not just during it), so a download that started just before the browser was closed by _ensure_chrome_closed is still recorded as download_started instead of reverting to active and eventually spawning duplicate downloads - Use get_download_status (lock-protected) in _is_download_active instead of direct dict access to avoid a TOCTOU race with stop_download - Clear next_check when a download stops so the scheduler re-checks on the very next tick; previously next_check was left at its original value (capped at end_dt), causing the scheduler to miss the resume window entirely when a stream crashed near the end of the time window Co-Authored-By: Claude Sonnet 4.6 --- app/scheduler.py | 42 ++++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/app/scheduler.py b/app/scheduler.py index e166b26..c4d9330 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -300,6 +300,7 @@ def _check_schedules(self): logger.info(f"Download {active_browser_id} stopped for schedule {schedule['id']}, resuming stream checks") schedule['status'] = 'active' schedule['active_browser_id'] = None + schedule['next_check'] = None # re-check immediately on next tick if schedule['status'] != 'active': next_check_val = schedule.get('next_check') @@ -374,6 +375,7 @@ def _check_daily_schedule(self, schedule, now): logger.info(f"Download {active_browser_id} stopped for schedule {schedule['id']}, resuming stream checks") schedule['status'] = 'active' schedule['active_browser_id'] = None + schedule['next_check'] = None # re-check immediately on next tick if schedule['status'] != 'active': next_check_val = schedule.get('next_check') @@ -501,25 +503,25 @@ def _is_download_active(self, browser_id): Check if a specific download is still active. Returns True if the download exists in the queue and has no completed_at. + Uses get_download_status (which holds _queue_lock) to avoid a TOCTOU race. """ try: if not browser_id: return False - download_queue = self.browser_service.download_service.download_queue + dl_status = self.browser_service.download_service.get_download_status(browser_id) - if browser_id in download_queue: - download_info = download_queue[browser_id] - is_active = 'completed_at' not in download_info - if is_active: - logger.debug(f"Download {browser_id} is still active") - else: - logger.debug(f"Download {browser_id} has completed") - return is_active - else: + if dl_status is None: logger.debug(f"Download {browser_id} not found in queue") return False + is_active = not dl_status.get('completed', False) + if is_active: + logger.debug(f"Download {browser_id} is still active") + else: + logger.debug(f"Download {browser_id} has completed") + return is_active + except Exception as e: logger.error(f"Error checking download status for {browser_id}: {e}") return False @@ -597,11 +599,19 @@ def _run_browser_check_task(self, schedule, duration): time.sleep(1) - # If no download started, revert to 'active' so next window check can retry + # Loop exited without detecting a download in the polling window. + # Check download_queue directly — the download callback may have fired + # just before the browser was closed externally (e.g. by _ensure_chrome_closed + # launching the next queued browser), causing the poll loop to miss it. with self.lock: for s in self.schedules: if s['id'] == schedule['id'] and s.get('status') == 'checking': - s['status'] = 'active' + if self._is_download_active(browser_id): + logger.info(f"Download {browser_id} found after loop exit for schedule {schedule['id']}, marking as started") + s['status'] = 'download_started' + s['active_browser_id'] = browser_id + else: + s['status'] = 'active' self._mark_dirty() break @@ -610,11 +620,15 @@ def _run_browser_check_task(self, schedule, duration): except Exception as e: logger.error(f"Error in browser check task: {e}") - # Revert checking status on error with self.lock: for s in self.schedules: if s['id'] == schedule['id'] and s.get('status') == 'checking': - s['status'] = 'active' + if self._is_download_active(browser_id): + logger.info(f"Download {browser_id} found after error for schedule {schedule['id']}, marking as started") + s['status'] = 'download_started' + s['active_browser_id'] = browser_id + else: + s['status'] = 'active' self._mark_dirty() break try: From d71869316f31a3d79083202988d76a4a931a840d Mon Sep 17 00:00:00 2001 From: DorkSoul Date: Thu, 18 Jun 2026 11:04:07 +0100 Subject: [PATCH 2/8] add DST-aware scheduling and custom time picker with clock face - Scheduler now stores IANA timezone per schedule and uses zoneinfo for DST-correct window comparisons; timezone sent from browser via Intl API - Replace native time inputs with custom TimePicker: 4 digit inputs (HH:MM) with auto-advance and backspace navigation - SVG analog clock face (24h, two-ring) for hour/minute selection Co-Authored-By: Claude Sonnet 4.6 --- app/routes/scheduler_routes.py | 6 +- app/scheduler.py | 118 +++++--- app/static/css/style.css | 136 +++++++++ app/static/js/init.js | 17 +- app/static/js/schedules.js | 489 +++++++++++---------------------- app/static/js/timepicker.js | 266 ++++++++++++++++++ app/templates/index.html | 29 +- 7 files changed, 672 insertions(+), 389 deletions(-) create mode 100644 app/static/js/timepicker.js diff --git a/app/routes/scheduler_routes.py b/app/routes/scheduler_routes.py index 1bc83b2..3bd8ab9 100644 --- a/app/routes/scheduler_routes.py +++ b/app/routes/scheduler_routes.py @@ -32,11 +32,12 @@ def add_schedule(): resolution = data.get('resolution', '1080p') framerate = data.get('framerate', 'any') format = data.get('format', 'mp4') + timezone = data.get('timezone') if not all([url, start_time, end_time]): return jsonify({'error': 'Missing required fields'}), 400 - schedule = scheduler.add_schedule(url, start_time, end_time, repeat, daily, name, resolution, framerate, format) + schedule = scheduler.add_schedule(url, start_time, end_time, repeat, daily, name, resolution, framerate, format, timezone) return jsonify({ 'success': True, @@ -74,13 +75,14 @@ def update_schedule(schedule_id): resolution = data.get('resolution', '1080p') framerate = data.get('framerate', 'any') format = data.get('format', 'mp4') + timezone = data.get('timezone') if not all([url, start_time, end_time]): return jsonify({'error': 'Missing required fields'}), 400 updated = scheduler.update_schedule( schedule_id, url, start_time, end_time, - repeat, daily, name, resolution, framerate, format + repeat, daily, name, resolution, framerate, format, timezone ) if updated: diff --git a/app/scheduler.py b/app/scheduler.py index c4d9330..dd198fc 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -5,7 +5,8 @@ import logging import threading import random -from datetime import datetime, timedelta +from datetime import datetime, timedelta, time as dtime +from zoneinfo import ZoneInfo logger = logging.getLogger(__name__) @@ -67,7 +68,37 @@ def _mark_dirty(self): """Mark schedules as needing a save.""" self._dirty = True - def add_schedule(self, url, start_time, end_time, repeat=False, daily=False, name=None, resolution='1080p', framerate='any', format='mp4'): + # ── Timezone helpers ────────────────────────────────────────── + + def _get_tz(self, schedule): + """Return a ZoneInfo for the schedule's timezone, or None for naive.""" + tz_name = schedule.get('timezone') if schedule else None + if tz_name: + try: + return ZoneInfo(tz_name) + except Exception: + pass + return None + + def _now(self, schedule=None): + """Return datetime.now(), timezone-aware when the schedule carries one.""" + tz = self._get_tz(schedule) + return datetime.now(tz) if tz else datetime.now() + + def _parse_dt(self, dt_str, tz): + """Parse an ISO datetime string and attach timezone if provided.""" + dt = datetime.fromisoformat(dt_str) + if tz and dt.tzinfo is None: + dt = dt.replace(tzinfo=tz) + return dt + + def _strip_tz(self, dt): + """Strip timezone for storage (timezone is persisted separately).""" + return dt.replace(tzinfo=None) if dt.tzinfo else dt + + # ── CRUD ────────────────────────────────────────────────────── + + def add_schedule(self, url, start_time, end_time, repeat=False, daily=False, name=None, resolution='1080p', framerate='any', format='mp4', timezone=None): """Add a new schedule""" with self.lock: schedule = { @@ -81,6 +112,7 @@ def add_schedule(self, url, start_time, end_time, repeat=False, daily=False, nam 'end_time': end_time, 'repeat': repeat, 'daily': daily, + 'timezone': timezone, 'status': 'pending', 'next_check': None, 'last_check': None, @@ -100,7 +132,7 @@ def remove_schedule(self, schedule_id): self.save_schedules() return True - def update_schedule(self, schedule_id, url, start_time, end_time, repeat=False, daily=False, name=None, resolution='1080p', framerate='any', format='mp4'): + def update_schedule(self, schedule_id, url, start_time, end_time, repeat=False, daily=False, name=None, resolution='1080p', framerate='any', format='mp4', timezone=None): """Update an existing schedule""" with self.lock: for schedule in self.schedules: @@ -114,6 +146,8 @@ def update_schedule(self, schedule_id, url, start_time, end_time, repeat=False, schedule['resolution'] = resolution schedule['framerate'] = framerate schedule['format'] = format + if timezone: + schedule['timezone'] = timezone schedule['status'] = 'pending' self._update_next_check(schedule) self._mark_dirty() @@ -259,8 +293,6 @@ def _run_loop(self): def _check_schedules(self): """Check all schedules and run tasks if needed""" - now = datetime.now() - with self.lock: for schedule in self.schedules: # Skip paused schedules entirely @@ -275,11 +307,15 @@ def _check_schedules(self): continue try: + # Compute now in the schedule's timezone (or naive if none stored) + tz = self._get_tz(schedule) + now = self._now(schedule) + if schedule.get('daily'): self._check_daily_schedule(schedule, now) else: - start_dt = datetime.fromisoformat(schedule['start_time']) - end_dt = datetime.fromisoformat(schedule['end_time']) + start_dt = self._parse_dt(schedule['start_time'], tz) + end_dt = self._parse_dt(schedule['end_time'], tz) if now > end_dt: if schedule['repeat']: @@ -306,7 +342,7 @@ def _check_schedules(self): next_check_val = schedule.get('next_check') if next_check_val: try: - next_check_dt = datetime.fromisoformat(next_check_val) + next_check_dt = self._parse_dt(next_check_val, tz) if next_check_dt <= end_dt: schedule['status'] = 'active' except (ValueError, TypeError): @@ -315,7 +351,7 @@ def _check_schedules(self): schedule['status'] = 'active' next_check = schedule.get('next_check') - if not next_check or now >= datetime.fromisoformat(next_check): + if not next_check or now >= self._parse_dt(next_check, tz): self._perform_check(schedule) elif now < start_dt: @@ -325,8 +361,7 @@ def _check_schedules(self): self._update_next_check(schedule) else: try: - next_check_dt = datetime.fromisoformat(next_check) - if next_check_dt < now: + if self._parse_dt(next_check, tz) < now: self._update_next_check(schedule) except (ValueError, TypeError): self._update_next_check(schedule) @@ -348,21 +383,25 @@ def _check_daily_schedule(self, schedule, now): """ start_time_str = schedule['start_time'] end_time_str = schedule['end_time'] + tz = self._get_tz(schedule) today = now.date() start_hour, start_min = map(int, start_time_str.split(':')) end_hour, end_min = map(int, end_time_str.split(':')) - start_dt = datetime.combine(today, datetime.min.time().replace(hour=start_hour, minute=start_min)) - end_dt = datetime.combine(today, datetime.min.time().replace(hour=end_hour, minute=end_min)) + def _combine(d, h, m): + return datetime.combine(d, dtime(h, m), tzinfo=tz) if tz else datetime.combine(d, dtime(h, m)) + + start_dt = _combine(today, start_hour, start_min) + end_dt = _combine(today, end_hour, end_min) spans_midnight = end_hour < start_hour or (end_hour == start_hour and end_min < start_min) if spans_midnight: - if now.time() < datetime.min.time().replace(hour=start_hour, minute=start_min): + if now.time().replace(tzinfo=None) < dtime(start_hour, start_min): yesterday = today - timedelta(days=1) - start_dt = datetime.combine(yesterday, datetime.min.time().replace(hour=start_hour, minute=start_min)) - end_dt = datetime.combine(today, datetime.min.time().replace(hour=end_hour, minute=end_min)) + start_dt = _combine(yesterday, start_hour, start_min) + end_dt = _combine(today, end_hour, end_min) else: end_dt = end_dt + timedelta(days=1) @@ -381,7 +420,7 @@ def _check_daily_schedule(self, schedule, now): next_check_val = schedule.get('next_check') if next_check_val: try: - next_check_dt = datetime.fromisoformat(next_check_val) + next_check_dt = self._parse_dt(next_check_val, tz) if next_check_dt <= end_dt: schedule['status'] = 'active' except (ValueError, TypeError): @@ -390,7 +429,7 @@ def _check_daily_schedule(self, schedule, now): schedule['status'] = 'active' next_check = schedule.get('next_check') - if not next_check or now >= datetime.fromisoformat(next_check): + if not next_check or now >= self._parse_dt(next_check, tz): self._perform_check(schedule) elif now < start_dt: @@ -400,8 +439,7 @@ def _check_daily_schedule(self, schedule, now): self._update_next_check(schedule) else: try: - next_check_dt = datetime.fromisoformat(next_check) - if next_check_dt < now: + if self._parse_dt(next_check, tz) < now: self._update_next_check(schedule) except (ValueError, TypeError): self._update_next_check(schedule) @@ -424,7 +462,11 @@ def _update_next_check(self, schedule): - After window: Schedule for next occurrence (daily/weekly) - Handles midnight-spanning windows correctly """ - now = datetime.now() + tz = self._get_tz(schedule) + now = self._now(schedule) + + def _store(dt): + return self._strip_tz(dt).isoformat() if schedule.get('daily'): start_time_str = schedule['start_time'] @@ -434,48 +476,50 @@ def _update_next_check(self, schedule): start_hour, start_min = map(int, start_time_str.split(':')) end_hour, end_min = map(int, end_time_str.split(':')) - start_dt = datetime.combine(today, datetime.min.time().replace(hour=start_hour, minute=start_min)) - end_dt = datetime.combine(today, datetime.min.time().replace(hour=end_hour, minute=end_min)) + def _combine(d, h, m): + return datetime.combine(d, dtime(h, m), tzinfo=tz) if tz else datetime.combine(d, dtime(h, m)) + + start_dt = _combine(today, start_hour, start_min) + end_dt = _combine(today, end_hour, end_min) spans_midnight = end_hour < start_hour or (end_hour == start_hour and end_min < start_min) if spans_midnight: - if now.time() < datetime.min.time().replace(hour=start_hour, minute=start_min): + if now.time().replace(tzinfo=None) < dtime(start_hour, start_min): yesterday = today - timedelta(days=1) - start_dt = datetime.combine(yesterday, datetime.min.time().replace(hour=start_hour, minute=start_min)) - end_dt = datetime.combine(today, datetime.min.time().replace(hour=end_hour, minute=end_min)) + start_dt = _combine(yesterday, start_hour, start_min) + end_dt = _combine(today, end_hour, end_min) else: end_dt = end_dt + timedelta(days=1) if now < start_dt: - schedule['next_check'] = start_dt.isoformat() + schedule['next_check'] = _store(start_dt) logger.debug(f"Schedule {schedule['id']}: next check set to window start: {start_dt}") elif start_dt <= now <= end_dt: minutes = random.uniform(5, 8) next_dt = min(now + timedelta(minutes=minutes), end_dt) - schedule['next_check'] = next_dt.isoformat() + schedule['next_check'] = _store(next_dt) logger.debug(f"Schedule {schedule['id']}: next check in {minutes:.1f} mins: {next_dt}") else: - if spans_midnight and now.time() < datetime.min.time().replace(hour=start_hour, minute=start_min): - next_start = datetime.combine(today, datetime.min.time().replace(hour=start_hour, minute=start_min)) + if spans_midnight and now.time().replace(tzinfo=None) < dtime(start_hour, start_min): + next_start = _combine(today, start_hour, start_min) else: - tomorrow = today + timedelta(days=1) - next_start = datetime.combine(tomorrow, datetime.min.time().replace(hour=start_hour, minute=start_min)) + next_start = _combine(today + timedelta(days=1), start_hour, start_min) - schedule['next_check'] = next_start.isoformat() + schedule['next_check'] = _store(next_start) logger.debug(f"Schedule {schedule['id']}: next check set to next window start: {next_start}") else: - start_dt = datetime.fromisoformat(schedule['start_time']) - end_dt = datetime.fromisoformat(schedule['end_time']) + start_dt = self._parse_dt(schedule['start_time'], tz) + end_dt = self._parse_dt(schedule['end_time'], tz) if now < start_dt: - schedule['next_check'] = start_dt.isoformat() + schedule['next_check'] = _store(start_dt) logger.debug(f"Schedule {schedule['id']}: next check set to window start: {start_dt}") elif start_dt <= now <= end_dt: minutes = random.uniform(5, 8) next_dt = min(now + timedelta(minutes=minutes), end_dt) - schedule['next_check'] = next_dt.isoformat() + schedule['next_check'] = _store(next_dt) logger.debug(f"Schedule {schedule['id']}: next check in {minutes:.1f} mins: {next_dt}") else: schedule['next_check'] = None diff --git a/app/static/css/style.css b/app/static/css/style.css index ed3bea6..4e26d53 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -575,3 +575,139 @@ select option { .stream-download-btn:active { background: #5664b2; } + +/* ── Time Picker ──────────────────────────────────────────── */ +.tp-widget { + position: relative; + display: inline-block; + width: 100%; +} + +.tp-display { + width: 100%; + padding: 12px; + background: #1e1e30; + border: 2px solid #4a4a6a; + border-radius: 8px; + color: #e0e0e0; + font-size: 1.15rem; + font-weight: 700; + letter-spacing: 2px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 1px; + transition: border-color 0.3s; +} + +.tp-display:hover, +.tp-display:focus { + outline: none; + border-color: #7e8ce0; +} + +.tp-sep { + color: #7e8ce0; + font-size: 1.3rem; +} + +.tp-popover { + position: absolute; + top: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + background: #2d2d44; + border: 1px solid #4a4a6a; + border-radius: 12px; + padding: 14px 16px 16px; + z-index: 9999; + box-shadow: 0 12px 40px rgba(0,0,0,0.7); + min-width: 252px; +} + +.tp-digits { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + margin-bottom: 10px; +} + +.tp-d { + width: 42px; + height: 50px; + background: #1e1e30; + border: 2px solid #4a4a6a; + border-radius: 8px; + color: #e0e0e0; + font-size: 1.4rem; + font-weight: 700; + text-align: center; + transition: border-color 0.2s; + caret-color: transparent; +} + +.tp-d:focus { + outline: none; + border-color: #7e8ce0; +} + +.tp-dsep { + color: #7e8ce0; + font-size: 1.6rem; + font-weight: 700; + line-height: 1; + margin: 0 2px; + user-select: none; +} + +.tp-modes { + display: flex; + gap: 6px; + margin-bottom: 10px; +} + +.tp-mode-btn { + flex: 1; + padding: 5px 0; + background: #1e1e30; + border: 1px solid #4a4a6a; + border-radius: 6px; + color: #b8b8d1; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 1px; + cursor: pointer; + transition: background 0.2s, color 0.2s, border-color 0.2s; +} + +.tp-mode-btn.active { + background: #7e8ce0; + border-color: #7e8ce0; + color: #fff; +} + +.tp-clock { + display: flex; + justify-content: center; +} + +/* Date + time side-by-side */ +.datetime-picker-group { + display: flex; + gap: 8px; + align-items: stretch; +} + +.datetime-picker-group .date-input { + flex: 1; + min-width: 0; + width: auto; + color-scheme: dark; +} + +.datetime-picker-group .tp-widget { + width: auto; + flex-shrink: 0; +} diff --git a/app/static/js/init.js b/app/static/js/init.js index 8403f9c..3e85f3c 100644 --- a/app/static/js/init.js +++ b/app/static/js/init.js @@ -1,22 +1,7 @@ // Initialization and event listeners document.addEventListener('DOMContentLoaded', () => { - // Set default schedule times to now and +1 hour - const now = new Date(); - const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000); - - // Format as YYYY-MM-DDTHH:MM for datetime-local input - const formatDateTime = (date) => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}-${month}-${day}T${hours}:${minutes}`; - }; - - document.getElementById('sched-start').value = formatDateTime(now); - document.getElementById('sched-end').value = formatDateTime(oneHourLater); + initSchedulePickers(); loadDownloads(); loadSchedules(); diff --git a/app/static/js/schedules.js b/app/static/js/schedules.js index 96bd16b..bf44d71 100644 --- a/app/static/js/schedules.js +++ b/app/static/js/schedules.js @@ -1,5 +1,55 @@ // Schedule management functions +// Module-level TimePicker instances (initialised by initSchedulePickers) +let schedStartTP, schedEndTP, editStartTP, editEndTP; + +function initSchedulePickers() { + const now = new Date(); + const later = new Date(now.getTime() + 3600000); + + schedStartTP = new TimePicker('sched-start-time', { initialValue: _fmtTime(now) }); + schedEndTP = new TimePicker('sched-end-time', { initialValue: _fmtTime(later) }); + editStartTP = new TimePicker('edit-sched-start-time', { initialValue: '00:00' }); + editEndTP = new TimePicker('edit-sched-end-time', { initialValue: '01:00' }); + + document.getElementById('sched-start-date').value = _fmtDate(now); + document.getElementById('sched-end-date').value = _fmtDate(later); +} + +// ── Helpers ────────────────────────────────────────────────── + +function _fmtDate(d) { + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; +} + +function _fmtTime(d) { + return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; +} + +function _getDatetime(dateId, tp) { + const date = document.getElementById(dateId).value; + const time = tp.getValue(); + return date && time ? `${date}T${time}` : ''; +} + +function _setDatetime(dateId, tp, iso) { + if (!iso) return; + if (iso.includes('T')) { + const [d, t] = iso.split('T'); + document.getElementById(dateId).value = d; + tp.setValue(t.substring(0, 5)); + } else { + tp.setValue(iso.substring(0, 5)); + } +} + +function _showDateInputs(groupId, visible) { + const el = document.getElementById(groupId)?.querySelector('.date-input'); + if (el) el.style.display = visible ? '' : 'none'; +} + +// ── Load / display ──────────────────────────────────────────── + async function loadSchedules() { try { const response = await fetch('/api/schedules/'); @@ -13,84 +63,46 @@ async function loadSchedules() { return; } - // Sort schedules: active first by next_check, paused always at bottom schedules.sort((a, b) => { if (a.paused && !b.paused) return 1; if (!a.paused && b.paused) return -1; - - // Helper function to get sortable timestamp - const getSortTime = (sched) => { - // If next_check exists, use it (it's already calculated by backend) - if (sched.next_check) { - return new Date(sched.next_check).getTime(); - } - - // Fallback to start_time for schedules without next_check - if (sched.daily) { - // Daily schedule - calculate next occurrence from time string + const getSortTime = (s) => { + if (s.next_check) return new Date(s.next_check).getTime(); + if (s.daily) { const now = new Date(); - const [hours, minutes] = sched.start_time.split(':').map(Number); - const nextRun = new Date(); - nextRun.setHours(hours, minutes, 0, 0); - - // If the time has passed today, schedule for tomorrow - if (nextRun <= now) { - nextRun.setDate(nextRun.getDate() + 1); - } - - return nextRun.getTime(); - } else { - // Regular schedule - use start_time directly - return new Date(sched.start_time).getTime(); + const [h, m] = s.start_time.split(':').map(Number); + const next = new Date(); next.setHours(h, m, 0, 0); + if (next <= now) next.setDate(next.getDate() + 1); + return next.getTime(); } + return new Date(s.start_time).getTime(); }; - - const timeA = getSortTime(a); - const timeB = getSortTime(b); - return timeA - timeB; + return getSortTime(a) - getSortTime(b); }); schedules.forEach(sched => { const item = document.createElement('div'); item.className = 'download-item'; - // Format dates as RFC 2822 without seconds (e.g., "Mon, 08 Dec 2025 14:03") const formatRFC2822 = (dateStr) => { const date = new Date(dateStr); - const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - - const day = days[date.getDay()]; - const dateNum = String(date.getDate()).padStart(2, '0'); - const month = months[date.getMonth()]; - const year = date.getFullYear(); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - - return `${day}, ${dateNum} ${month} ${year} ${hours}:${minutes}`; + const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + return `${days[date.getDay()]}, ${String(date.getDate()).padStart(2,'0')} ${months[date.getMonth()]} ${date.getFullYear()} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`; }; - let windowText = ''; - let repeatText = ''; - + let windowText = '', repeatText = ''; if (sched.daily) { - // Daily schedule - just show times - windowText = `${sched.start_time} - ${sched.end_time} (Daily)`; + windowText = `${sched.start_time} – ${sched.end_time} (Daily)`; repeatText = 'Daily'; } else { - // Regular schedule - show full datetime - const start = formatRFC2822(sched.start_time); - const end = formatRFC2822(sched.end_time); - windowText = `${start} - ${end}`; + windowText = `${formatRFC2822(sched.start_time)} – ${formatRFC2822(sched.end_time)}`; repeatText = sched.repeat ? 'Weekly' : 'Once'; } - let statusColor = '#666'; - let statusText = sched.status; - + let statusColor = '#666', statusText = sched.status; if (sched.paused) { - statusColor = '#888'; - statusText = 'paused'; + statusColor = '#888'; statusText = 'paused'; } else if (sched.status === 'active') { statusColor = '#28a745'; const nextCheck = sched.next_check ? formatRFC2822(sched.next_check) : 'Pending window'; @@ -98,146 +110,98 @@ async function loadSchedules() { } else if (sched.status === 'download_started') { statusColor = '#17a2b8'; statusText = 'Download started (Recording in progress)'; - } else if (sched.status === 'pending') { + } else if (sched.status === 'pending' && sched.next_check) { statusColor = '#666'; - if (sched.next_check) { - const nextCheck = formatRFC2822(sched.next_check); - statusText = `${sched.status} (Next check: ${nextCheck})`; - } else { - statusText = sched.status; - } - } else { - statusText = sched.status; + statusText = `${sched.status} (Next check: ${formatRFC2822(sched.next_check)})`; } item.innerHTML = ` -
-
-

${sched.url}

+
+
+

${sched.url}

Window: ${windowText}

Repeat: ${repeatText}

-

Status: ${statusText}

+

Status: ${statusText}

-
- - - +
+ + +
-
- `; +
`; container.appendChild(item); }); - } catch (error) { console.error('Error loading schedules:', error); } } +// ── Toggle daily mode ───────────────────────────────────────── + function toggleDailySchedule() { const daily = document.getElementById('sched-daily').checked; - const startInput = document.getElementById('sched-start'); - const endInput = document.getElementById('sched-end'); - const startLabel = document.getElementById('sched-start-label'); - const endLabel = document.getElementById('sched-end-label'); const repeatContainer = document.getElementById('sched-repeat-container'); - const repeatCheckbox = document.getElementById('sched-repeat'); - - if (daily) { - // Hide weekly repeat option (daily already repeats) - repeatContainer.style.display = 'none'; - repeatCheckbox.checked = false; - - // Convert to time inputs - startInput.type = 'time'; - endInput.type = 'time'; - startLabel.textContent = 'Start Time (Daily)'; - endLabel.textContent = 'End Time (Daily)'; - - // Set default time values if empty - if (!startInput.value) { - const now = new Date(); - startInput.value = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; - } - if (!endInput.value) { - const oneHourLater = new Date(new Date().getTime() + 60 * 60 * 1000); - endInput.value = `${String(oneHourLater.getHours()).padStart(2, '0')}:${String(oneHourLater.getMinutes()).padStart(2, '0')}`; - } - } else { - // Show weekly repeat option - repeatContainer.style.display = 'block'; - - // Convert to datetime-local inputs - startInput.type = 'datetime-local'; - endInput.type = 'datetime-local'; - startLabel.textContent = 'Start Time'; - endLabel.textContent = 'End Time'; - - // Set default datetime values - const now = new Date(); - const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000); - const formatDateTime = (date) => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}-${month}-${day}T${hours}:${minutes}`; - }; - startInput.value = formatDateTime(now); - endInput.value = formatDateTime(oneHourLater); - } + document.getElementById('sched-repeat').checked = false; + + document.getElementById('sched-start-label').textContent = daily ? 'Start Time (Daily)' : 'Start Time'; + document.getElementById('sched-end-label').textContent = daily ? 'End Time (Daily)' : 'End Time'; + + _showDateInputs('sched-start-group', !daily); + _showDateInputs('sched-end-group', !daily); + repeatContainer.style.display = daily ? 'none' : 'block'; } +function toggleEditDailySchedule() { + const daily = document.getElementById('edit-sched-daily').checked; + document.getElementById('edit-sched-repeat').checked = false; + + document.getElementById('edit-sched-start-label').textContent = daily ? 'Start Time (Daily)' : 'Start Time'; + document.getElementById('edit-sched-end-label').textContent = daily ? 'End Time (Daily)' : 'End Time'; + + _showDateInputs('edit-sched-start-group', !daily); + _showDateInputs('edit-sched-end-group', !daily); + document.getElementById('edit-sched-repeat-container').style.display = daily ? 'none' : 'block'; +} + +// ── Add schedule ────────────────────────────────────────────── + async function addSchedule() { - let url = document.getElementById('sched-url').value.trim(); + let url = document.getElementById('sched-url').value.trim(); const name = document.getElementById('sched-name').value.trim(); - const start = document.getElementById('sched-start').value; - const end = document.getElementById('sched-end').value; - const repeat = document.getElementById('sched-repeat').checked; - const daily = document.getElementById('sched-daily').checked; + const repeat = document.getElementById('sched-repeat').checked; + const daily = document.getElementById('sched-daily').checked; const resolution = document.getElementById('sched-resolution').value; - const framerate = document.getElementById('sched-framerate').value; - const format = document.getElementById('sched-format').value; - const statusBox = document.getElementById('sched-status'); + const framerate = document.getElementById('sched-framerate').value; + const format = document.getElementById('sched-format').value; + const statusBox = document.getElementById('sched-status'); + + const start = daily ? schedStartTP.getValue() : _getDatetime('sched-start-date', schedStartTP); + const end = daily ? schedEndTP.getValue() : _getDatetime('sched-end-date', schedEndTP); if (!url || !start || !end) { showStatus(statusBox, 'Please fill all fields', 'error'); return; } - // Add https:// if no protocol specified - if (!url.startsWith('http://') && !url.startsWith('https://')) { - url = 'https://' + url; - } + if (!url.startsWith('http://') && !url.startsWith('https://')) url = 'https://' + url; - // Prepare request body const requestBody = { - url: url, - start_time: start, - end_time: end, - repeat: repeat, - daily: daily, - resolution: resolution, - framerate: framerate, - format: format + url, start_time: start, end_time: end, + repeat, daily, resolution, framerate, format, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }; - - // Only include name if it's not empty - if (name) { - requestBody.name = name; - } + if (name) requestBody.name = name; try { const response = await fetch('/api/schedules/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) + body: JSON.stringify(requestBody), }); - const data = await response.json(); if (data.success) { showStatus(statusBox, 'Schedule added!', 'success'); - document.getElementById('sched-url').value = ''; + document.getElementById('sched-url').value = ''; document.getElementById('sched-name').value = ''; loadSchedules(); } else { @@ -248,228 +212,103 @@ async function addSchedule() { } } +// ── Pause / delete / refresh ────────────────────────────────── + async function pauseSchedule(id, btn) { try { btn.disabled = true; const response = await fetch(`/api/schedules/${id}/pause`, { method: 'POST' }); const data = await response.json(); - - if (data.success) { - loadSchedules(); - } else { - alert('Error: ' + data.error); - btn.disabled = false; - } - } catch (error) { - console.error(error); - btn.disabled = false; - } + if (data.success) { loadSchedules(); } else { alert('Error: ' + data.error); btn.disabled = false; } + } catch (error) { console.error(error); btn.disabled = false; } } async function deleteSchedule(id, btn) { if (!confirm('Delete this schedule?')) return; - try { btn.disabled = true; const response = await fetch(`/api/schedules/${id}`, { method: 'DELETE' }); const data = await response.json(); - - if (data.success) { - loadSchedules(); - } else { - alert('Error: ' + data.error); - btn.disabled = false; - } - } catch (error) { - console.error(error); - btn.disabled = false; - } + if (data.success) { loadSchedules(); } else { alert('Error: ' + data.error); btn.disabled = false; } + } catch (error) { console.error(error); btn.disabled = false; } } async function refreshScheduleTimes() { try { const response = await fetch('/api/schedules/refresh', { method: 'POST' }); const data = await response.json(); - - if (data.success) { - alert(`✓ Refreshed ${data.count} schedule(s)`); - loadSchedules(); - } else { - alert('Error: ' + data.error); - } - } catch (error) { - console.error(error); - alert('Error refreshing schedules: ' + error.message); - } + if (data.success) { alert(`✓ Refreshed ${data.count} schedule(s)`); loadSchedules(); } + else { alert('Error: ' + data.error); } + } catch (error) { alert('Error refreshing schedules: ' + error.message); } } +// ── Edit schedule modal ─────────────────────────────────────── function editSchedule(schedule) { - // Store the schedule ID AppState.currentEditScheduleId = schedule.id; - // Populate the form fields - document.getElementById('edit-sched-url').value = schedule.url; - document.getElementById('edit-sched-name').value = schedule.name || ''; + document.getElementById('edit-sched-url').value = schedule.url; + document.getElementById('edit-sched-name').value = schedule.name || ''; document.getElementById('edit-sched-resolution').value = schedule.resolution || 'source'; - document.getElementById('edit-sched-framerate').value = schedule.framerate || 'any'; - document.getElementById('edit-sched-format').value = schedule.format || 'mp4'; - document.getElementById('edit-sched-repeat').checked = schedule.repeat || false; - document.getElementById('edit-sched-daily').checked = schedule.daily || false; - - // Set up time inputs based on whether it's a daily schedule - const startInput = document.getElementById('edit-sched-start'); - const endInput = document.getElementById('edit-sched-end'); - const startLabel = document.getElementById('edit-sched-start-label'); - const endLabel = document.getElementById('edit-sched-end-label'); - const repeatContainer = document.getElementById('edit-sched-repeat-container'); - - if (schedule.daily) { - // Daily schedule - use time inputs and hide weekly repeat - startInput.type = 'time'; - endInput.type = 'time'; - startLabel.textContent = 'Start Time (Daily)'; - endLabel.textContent = 'End Time (Daily)'; - startInput.value = schedule.start_time; - endInput.value = schedule.end_time; - repeatContainer.style.display = 'none'; - } else { - // Regular schedule - use datetime-local inputs and show weekly repeat - startInput.type = 'datetime-local'; - endInput.type = 'datetime-local'; - startLabel.textContent = 'Start Time'; - endLabel.textContent = 'End Time'; - startInput.value = schedule.start_time; - endInput.value = schedule.end_time; - repeatContainer.style.display = 'block'; - } - - // Clear status - const statusBox = document.getElementById('edit-sched-status'); - statusBox.classList.remove('active', 'success', 'error'); - - // Show modal + document.getElementById('edit-sched-framerate').value = schedule.framerate || 'any'; + document.getElementById('edit-sched-format').value = schedule.format || 'mp4'; + document.getElementById('edit-sched-repeat').checked = schedule.repeat || false; + document.getElementById('edit-sched-daily').checked = schedule.daily || false; + + const daily = schedule.daily || false; + document.getElementById('edit-sched-start-label').textContent = daily ? 'Start Time (Daily)' : 'Start Time'; + document.getElementById('edit-sched-end-label').textContent = daily ? 'End Time (Daily)' : 'End Time'; + _showDateInputs('edit-sched-start-group', !daily); + _showDateInputs('edit-sched-end-group', !daily); + document.getElementById('edit-sched-repeat-container').style.display = daily ? 'none' : 'block'; + + _setDatetime('edit-sched-start-date', editStartTP, schedule.start_time); + _setDatetime('edit-sched-end-date', editEndTP, schedule.end_time); + + document.getElementById('edit-sched-status').classList.remove('active', 'success', 'error'); document.getElementById('edit-schedule-modal').classList.add('active'); } -function toggleEditDailySchedule() { - const daily = document.getElementById('edit-sched-daily').checked; - const startInput = document.getElementById('edit-sched-start'); - const endInput = document.getElementById('edit-sched-end'); - const startLabel = document.getElementById('edit-sched-start-label'); - const endLabel = document.getElementById('edit-sched-end-label'); - const repeatContainer = document.getElementById('edit-sched-repeat-container'); - const repeatCheckbox = document.getElementById('edit-sched-repeat'); - - if (daily) { - // Hide weekly repeat option (daily already repeats) - repeatContainer.style.display = 'none'; - repeatCheckbox.checked = false; - - // Convert to time inputs - startInput.type = 'time'; - endInput.type = 'time'; - startLabel.textContent = 'Start Time (Daily)'; - endLabel.textContent = 'End Time (Daily)'; - - // If values are datetime, extract time part - if (startInput.value && startInput.value.includes('T')) { - startInput.value = startInput.value.split('T')[1]; - } - if (endInput.value && endInput.value.includes('T')) { - endInput.value = endInput.value.split('T')[1]; - } - } else { - // Show weekly repeat option - repeatContainer.style.display = 'block'; - - // Convert to datetime-local inputs - startInput.type = 'datetime-local'; - endInput.type = 'datetime-local'; - startLabel.textContent = 'Start Time'; - endLabel.textContent = 'End Time'; - - // If values are just times, convert to datetime - if (startInput.value && !startInput.value.includes('T')) { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - startInput.value = `${year}-${month}-${day}T${startInput.value}`; - } - if (endInput.value && !endInput.value.includes('T')) { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - endInput.value = `${year}-${month}-${day}T${endInput.value}`; - } - } -} - function closeEditScheduleModal() { document.getElementById('edit-schedule-modal').classList.remove('active'); AppState.currentEditScheduleId = null; } async function updateSchedule() { - if (!AppState.currentEditScheduleId) { - alert('Error: No schedule selected for editing'); - return; - } + if (!AppState.currentEditScheduleId) { alert('Error: No schedule selected for editing'); return; } - let url = document.getElementById('edit-sched-url').value.trim(); + let url = document.getElementById('edit-sched-url').value.trim(); const name = document.getElementById('edit-sched-name').value.trim(); - const start = document.getElementById('edit-sched-start').value; - const end = document.getElementById('edit-sched-end').value; - const repeat = document.getElementById('edit-sched-repeat').checked; - const daily = document.getElementById('edit-sched-daily').checked; + const repeat = document.getElementById('edit-sched-repeat').checked; + const daily = document.getElementById('edit-sched-daily').checked; const resolution = document.getElementById('edit-sched-resolution').value; - const framerate = document.getElementById('edit-sched-framerate').value; - const format = document.getElementById('edit-sched-format').value; - const statusBox = document.getElementById('edit-sched-status'); + const framerate = document.getElementById('edit-sched-framerate').value; + const format = document.getElementById('edit-sched-format').value; + const statusBox = document.getElementById('edit-sched-status'); - if (!url || !start || !end) { - showStatus(statusBox, 'Please fill all fields', 'error'); - return; - } + const start = daily ? editStartTP.getValue() : _getDatetime('edit-sched-start-date', editStartTP); + const end = daily ? editEndTP.getValue() : _getDatetime('edit-sched-end-date', editEndTP); - // Add https:// if no protocol specified - if (!url.startsWith('http://') && !url.startsWith('https://')) { - url = 'https://' + url; - } + if (!url || !start || !end) { showStatus(statusBox, 'Please fill all fields', 'error'); return; } + if (!url.startsWith('http://') && !url.startsWith('https://')) url = 'https://' + url; - // Prepare request body const requestBody = { - url: url, - start_time: start, - end_time: end, - repeat: repeat, - daily: daily, - resolution: resolution, - framerate: framerate, - format: format + url, start_time: start, end_time: end, + repeat, daily, resolution, framerate, format, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }; - - // Only include name if it's not empty - if (name) { - requestBody.name = name; - } + if (name) requestBody.name = name; try { const response = await fetch(`/api/schedules/${AppState.currentEditScheduleId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) + body: JSON.stringify(requestBody), }); - const data = await response.json(); if (data.success) { showStatus(statusBox, 'Schedule updated!', 'success'); - setTimeout(() => { - closeEditScheduleModal(); - loadSchedules(); - }, 1000); + setTimeout(() => { closeEditScheduleModal(); loadSchedules(); }, 1000); } else { showStatus(statusBox, 'Error: ' + data.error, 'error'); } @@ -477,5 +316,3 @@ async function updateSchedule() { showStatus(statusBox, 'Error: ' + error.message, 'error'); } } - -// Initialize diff --git a/app/static/js/timepicker.js b/app/static/js/timepicker.js new file mode 100644 index 0000000..dad2a5e --- /dev/null +++ b/app/static/js/timepicker.js @@ -0,0 +1,266 @@ +class TimePicker { + constructor(containerId, options = {}) { + this.containerId = containerId; + this.container = document.getElementById(containerId); + if (!this.container) return; + + this.hours = 0; + this.minutes = 0; + this.mode = 'hour'; + this.onChange = options.onChange || null; + this._pop = null; + this._outsideClick = this._onOutside.bind(this); + + if (options.initialValue) this._parse(options.initialValue); + this._build(); + } + + getValue() { + return `${String(this.hours).padStart(2,'0')}:${String(this.minutes).padStart(2,'0')}`; + } + + setValue(hhmm) { + this._parse(hhmm); + this._refreshBtn(); + } + + _parse(hhmm) { + if (!hhmm) return; + const [h, m] = hhmm.split(':').map(Number); + this.hours = isNaN(h) ? 0 : Math.min(23, Math.max(0, h)); + this.minutes = isNaN(m) ? 0 : Math.min(59, Math.max(0, m)); + } + + _build() { + this.container.innerHTML = ''; + this.container.className = 'tp-widget'; + this._btn = document.createElement('button'); + this._btn.type = 'button'; + this._btn.className = 'tp-display'; + this._btn.addEventListener('click', e => { e.stopPropagation(); this._toggle(); }); + this.container.appendChild(this._btn); + this._refreshBtn(); + } + + _refreshBtn() { + const hh = String(this.hours).padStart(2,'0'); + const mm = String(this.minutes).padStart(2,'0'); + this._btn.innerHTML = + `${hh}:${mm}`; + } + + _toggle() { this._pop ? this._close() : this._open(); } + + _open() { + this.mode = 'hour'; + const pop = document.createElement('div'); + pop.className = 'tp-popover'; + const id = this.containerId; + pop.innerHTML = ` +
+ + + : + + +
+
+ + +
+
`; + this._pop = pop; + this.container.appendChild(pop); + this._syncDigits(); + this._hookDigits(); + this._hookModes(); + this._drawClock(); + setTimeout(() => document.addEventListener('click', this._outsideClick), 0); + } + + _close() { + if (this._pop) { this._pop.remove(); this._pop = null; } + document.removeEventListener('click', this._outsideClick); + } + + _onOutside(e) { if (!this.container.contains(e.target)) this._close(); } + + _d(i) { return document.getElementById(`${this.containerId}__d${i}`); } + + _syncDigits() { + const hh = String(this.hours).padStart(2,'0'); + const mm = String(this.minutes).padStart(2,'0'); + [hh[0], hh[1], mm[0], mm[1]].forEach((v, i) => { const el = this._d(i); if (el) el.value = v; }); + } + + _hookDigits() { + for (let i = 0; i < 4; i++) { + const inp = this._d(i); + if (!inp) continue; + inp.addEventListener('keydown', e => { + if (e.key === 'Backspace' && !inp.value && i > 0) { + const prev = this._d(i - 1); + if (prev) { prev.value = ''; prev.focus(); } + } + }); + inp.addEventListener('input', () => { + inp.value = inp.value.replace(/[^0-9]/g, '').slice(-1); + this._applyDigits(); + if (inp.value && i < 3) { const next = this._d(i + 1); if (next) next.focus(); } + }); + } + } + + _applyDigits() { + const v = [0,1,2,3].map(i => { const el = this._d(i); return el && el.value !== '' ? parseInt(el.value) : null; }); + if (v[0] !== null && v[1] !== null) { + const h = v[0] * 10 + v[1]; + if (h <= 23) this.hours = h; + } + if (v[2] !== null && v[3] !== null) { + const m = v[2] * 10 + v[3]; + if (m <= 59) this.minutes = m; + } + this._refreshBtn(); + this._drawClock(); + if (this.onChange) this.onChange(this.getValue()); + } + + _hookModes() { + const hr = document.getElementById(`${this.containerId}__hr`); + const mn = document.getElementById(`${this.containerId}__mn`); + hr?.addEventListener('click', e => { e.stopPropagation(); this.mode = 'hour'; this._drawClock(); }); + mn?.addEventListener('click', e => { e.stopPropagation(); this.mode = 'minute'; this._drawClock(); }); + } + + _polar(deg, r, cx, cy) { + const rad = (deg - 90) * Math.PI / 180; + return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }; + } + + _drawClock() { + const wrap = document.getElementById(`${this.containerId}__clock`); + if (!wrap) return; + + const isHour = this.mode === 'hour'; + const S = 220, cx = S / 2, cy = S / 2; + const outerR = 88, innerR = 58; + const ns = 'http://www.w3.org/2000/svg'; + + const svg = document.createElementNS(ns, 'svg'); + svg.setAttribute('viewBox', `0 0 ${S} ${S}`); + svg.setAttribute('width', S); + svg.setAttribute('height', S); + svg.style.cursor = 'pointer'; + + // Background circle + const bg = document.createElementNS(ns, 'circle'); + bg.setAttribute('cx', cx); bg.setAttribute('cy', cy); bg.setAttribute('r', S / 2 - 2); + bg.setAttribute('fill', '#1e2040'); + svg.appendChild(bg); + + // Hand + let handAngle; + if (isHour) { + const h = this.hours; + handAngle = h === 0 ? 0 : h <= 12 ? h * 30 : (h - 12) * 30; + } else { + handAngle = (this.minutes / 60) * 360; + } + const handR = isHour + ? (this.hours >= 1 && this.hours <= 12 ? outerR : innerR) - 14 + : outerR - 14; + const handTip = this._polar(handAngle, handR, cx, cy); + + const line = document.createElementNS(ns, 'line'); + line.setAttribute('x1', cx); line.setAttribute('y1', cy); + line.setAttribute('x2', handTip.x); line.setAttribute('y2', handTip.y); + line.setAttribute('stroke', '#7e8ce0'); line.setAttribute('stroke-width', '2'); + svg.appendChild(line); + + const dot = document.createElementNS(ns, 'circle'); + dot.setAttribute('cx', cx); dot.setAttribute('cy', cy); dot.setAttribute('r', 3); + dot.setAttribute('fill', '#7e8ce0'); + svg.appendChild(dot); + + // Numbers + if (isHour) { + // Outer ring: 12, 1–11 + for (let slot = 0; slot < 12; slot++) { + const h = slot === 0 ? 12 : slot; + this._addNum(svg, ns, h, this._polar(slot * 30, outerR, cx, cy), this.hours === h, 13); + } + // Inner ring: 0, 13–23 + const inner = [0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]; + for (let slot = 0; slot < 12; slot++) { + const h = inner[slot]; + this._addNum(svg, ns, h, this._polar(slot * 30, innerR, cx, cy), this.hours === h, 10); + } + } else { + for (let slot = 0; slot < 12; slot++) { + const m = slot * 5; + const label = String(m).padStart(2, '0'); + this._addNum(svg, ns, label, this._polar(slot * 30, outerR, cx, cy), this.minutes === m, 13); + } + } + + svg.addEventListener('click', e => this._clockClick(e, cx, cy, outerR, innerR, isHour, S)); + wrap.innerHTML = ''; + wrap.appendChild(svg); + + // Sync mode button active states + document.getElementById(`${this.containerId}__hr`)?.classList.toggle('active', isHour); + document.getElementById(`${this.containerId}__mn`)?.classList.toggle('active', !isHour); + } + + _addNum(svg, ns, label, pos, selected, fontSize) { + if (selected) { + const bg = document.createElementNS(ns, 'circle'); + bg.setAttribute('cx', pos.x); bg.setAttribute('cy', pos.y); + bg.setAttribute('r', fontSize >= 13 ? 14 : 11); + bg.setAttribute('fill', '#7e8ce0'); + svg.appendChild(bg); + } + const txt = document.createElementNS(ns, 'text'); + txt.setAttribute('x', pos.x); txt.setAttribute('y', pos.y); + txt.setAttribute('text-anchor', 'middle'); + txt.setAttribute('dominant-baseline', 'central'); + txt.setAttribute('fill', selected ? '#fff' : '#b8b8d1'); + txt.setAttribute('font-size', fontSize); + txt.setAttribute('font-family', 'system-ui, sans-serif'); + txt.textContent = label; + svg.appendChild(txt); + } + + _clockClick(e, cx, cy, outerR, innerR, isHour, S) { + const rect = e.currentTarget.getBoundingClientRect(); + const sx = S / rect.width, sy = S / rect.height; + const x = (e.clientX - rect.left) * sx - cx; + const y = (e.clientY - rect.top) * sy - cy; + const dist = Math.sqrt(x * x + y * y); + + let angle = Math.atan2(y, x) * 180 / Math.PI + 90; + if (angle < 0) angle += 360; + const slot = Math.round(angle / 30) % 12; + + if (isHour) { + if (dist < (outerR + innerR) / 2) { + this.hours = [0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23][slot]; + } else { + this.hours = slot === 0 ? 12 : slot; + } + this.mode = 'minute'; + this._refreshBtn(); + this._syncDigits(); + this._drawClock(); + } else { + this.minutes = slot * 5; + this._refreshBtn(); + this._syncDigits(); + if (this.onChange) this.onChange(this.getValue()); + this._close(); + return; + } + if (this.onChange) this.onChange(this.getValue()); + } +} diff --git a/app/templates/index.html b/app/templates/index.html index 17b6e99..33fec5b 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -198,12 +198,18 @@

Add New Sche
- - + +
+ +
+
- - + +
+ +
+
@@ -307,6 +313,7 @@

Completed Downloads

+ @@ -409,12 +416,18 @@

✏️ Edit Schedule

- - + +
+ +
+
- - + +
+ +
+
From 4a3a7d703fb53fffcd046507c30702d03e773d34 Mon Sep 17 00:00:00 2001 From: DorkSoul Date: Thu, 18 Jun 2026 11:20:50 +0100 Subject: [PATCH 3/8] auto-pause schedules on manual download; fix timepicker off-screen on narrow displays - Starting a browser or direct download now auto-pauses all active schedules; they auto-resume when the browser is closed or download stops - TimePicker popover repositions itself after render if it clips the right or left viewport edge; CSS max-width prevents overflow on portrait screens Co-Authored-By: Claude Sonnet 4.6 --- app/app.py | 2 +- app/routes/browser_routes.py | 6 +++++- app/routes/download_routes.py | 13 +++++++++---- app/scheduler.py | 32 ++++++++++++++++++++++++++++++++ app/static/css/style.css | 2 ++ app/static/js/timepicker.js | 15 +++++++++++++++ 6 files changed, 64 insertions(+), 6 deletions(-) diff --git a/app/app.py b/app/app.py index a3d3044..3fddf03 100644 --- a/app/app.py +++ b/app/app.py @@ -30,7 +30,7 @@ def create_app(): browser_service.check_chrome_installation() # Initialize and register routes (pass config to browser routes for test endpoint) - browser_bp = init_browser_routes(browser_service, download_service, config) + browser_bp = init_browser_routes(browser_service, download_service, config, scheduler) download_bp = init_download_routes(download_service, config.DOWNLOAD_DIR, scheduler) scheduler_bp = init_scheduler_routes(scheduler) events_bp = init_events_routes(browser_service, download_service) diff --git a/app/routes/browser_routes.py b/app/routes/browser_routes.py index a8b15cd..689b34b 100644 --- a/app/routes/browser_routes.py +++ b/app/routes/browser_routes.py @@ -11,7 +11,7 @@ browser_bp = Blueprint('browser', __name__, url_prefix='/api/browser') -def init_browser_routes(browser_service, download_service, config): +def init_browser_routes(browser_service, download_service, config, scheduler=None): """Initialize browser routes with services""" @browser_bp.route('/start', methods=['POST']) @@ -40,6 +40,8 @@ def start_browser(): ) if success: + if scheduler: + scheduler.pause_all_for_manual(browser_id) return jsonify({ 'success': True, 'browser_id': browser_id, @@ -86,6 +88,8 @@ def close_browser(browser_id): """Close browser manually""" try: if browser_service.close_browser(browser_id): + if scheduler: + scheduler.resume_after_manual(browser_id) return jsonify({'success': True, 'message': 'Browser closed'}) else: return jsonify({'error': 'Browser not found'}), 404 diff --git a/app/routes/download_routes.py b/app/routes/download_routes.py index daa8f1d..19c86b5 100644 --- a/app/routes/download_routes.py +++ b/app/routes/download_routes.py @@ -151,6 +151,9 @@ def download_direct(): filename ) + if scheduler: + scheduler.pause_all_for_manual(browser_id) + return jsonify({ 'success': True, 'browser_id': browser_id, @@ -241,10 +244,12 @@ def stop_download(browser_id): """Stop an active download""" try: if download_service.stop_download(browser_id): - # If this is a scheduled download, move it to the next time slot - if scheduler and browser_id.startswith('sched_'): - scheduler.move_to_next_slot(browser_id) - logger.info(f"Moved scheduled download {browser_id} to next time slot") + if scheduler: + if browser_id.startswith('sched_'): + scheduler.move_to_next_slot(browser_id) + logger.info(f"Moved scheduled download {browser_id} to next time slot") + else: + scheduler.resume_after_manual(browser_id) return jsonify({'success': True, 'message': 'Download stopped'}) else: diff --git a/app/scheduler.py b/app/scheduler.py index dd198fc..02dda0d 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -31,6 +31,7 @@ def __init__(self, config, browser_service): self.thread = None self.lock = threading.Lock() self._dirty = False # True when in-memory schedules differ from disk + self._auto_paused = {} # browser_id -> [schedule_ids paused for that manual session] self.load_schedules() @@ -224,6 +225,37 @@ def pause_schedule(self, schedule_id): return schedule return None + def pause_all_for_manual(self, browser_id): + """Pause all non-paused schedules while a manual download is active.""" + paused_ids = [] + with self.lock: + for schedule in self.schedules: + if not schedule.get('paused', False): + schedule['paused'] = True + schedule['status'] = 'paused' + paused_ids.append(schedule['id']) + if paused_ids: + self._auto_paused[browser_id] = paused_ids + self._mark_dirty() + self.save_schedules() + if paused_ids: + logger.info(f"Auto-paused {len(paused_ids)} schedule(s) for manual session {browser_id}") + + def resume_after_manual(self, browser_id): + """Resume schedules that were auto-paused for a manual session.""" + ids_to_resume = self._auto_paused.pop(browser_id, []) + if not ids_to_resume: + return + with self.lock: + for schedule in self.schedules: + if schedule['id'] in ids_to_resume: + schedule['paused'] = False + schedule['status'] = 'pending' + self._update_next_check(schedule) + self._mark_dirty() + self.save_schedules() + logger.info(f"Auto-resumed {len(ids_to_resume)} schedule(s) after manual session {browser_id}") + def get_schedules(self): """Get all schedules — active schedules sorted by next_check, paused schedules at the bottom.""" sorted_schedules = sorted( diff --git a/app/static/css/style.css b/app/static/css/style.css index 4e26d53..57db540 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -624,6 +624,8 @@ select option { z-index: 9999; box-shadow: 0 12px 40px rgba(0,0,0,0.7); min-width: 252px; + max-width: calc(100vw - 16px); + box-sizing: border-box; } .tp-digits { diff --git a/app/static/js/timepicker.js b/app/static/js/timepicker.js index dad2a5e..4f49ba6 100644 --- a/app/static/js/timepicker.js +++ b/app/static/js/timepicker.js @@ -75,9 +75,24 @@ class TimePicker { this._hookDigits(); this._hookModes(); this._drawClock(); + requestAnimationFrame(() => this._reposition()); setTimeout(() => document.addEventListener('click', this._outsideClick), 0); } + _reposition() { + if (!this._pop) return; + const rect = this._pop.getBoundingClientRect(); + const vw = window.innerWidth; + if (rect.right > vw - 8) { + this._pop.style.left = 'auto'; + this._pop.style.right = '0'; + this._pop.style.transform = 'none'; + } else if (rect.left < 8) { + this._pop.style.left = '0'; + this._pop.style.transform = 'none'; + } + } + _close() { if (this._pop) { this._pop.remove(); this._pop = null; } document.removeEventListener('click', this._outsideClick); From 45d37e7993d6c062dd43ab9b03af68b1998be910 Mon Sep 17 00:00:00 2001 From: DorkSoul Date: Thu, 18 Jun 2026 11:30:48 +0100 Subject: [PATCH 4/8] fix next_check display time being 1 hour off in BST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store next_check as UTC ISO with Z suffix (e.g. 2026-06-18T10:28:00Z) so JavaScript's Date() automatically converts to the browser's local timezone. Previously, naive datetime strings were stored which JS treated as local time — schedules without a timezone field (created before the DST fix) produced UTC wall-clock strings that displayed 1 hour behind in BST. Also update _parse_dt to handle aware/naive mixed comparisons, and fix move_to_next_slot to use timezone-correct datetime construction. Co-Authored-By: Claude Sonnet 4.6 --- app/scheduler.py | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/app/scheduler.py b/app/scheduler.py index 02dda0d..37aedff 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -87,16 +87,34 @@ def _now(self, schedule=None): return datetime.now(tz) if tz else datetime.now() def _parse_dt(self, dt_str, tz): - """Parse an ISO datetime string and attach timezone if provided.""" + """Parse an ISO datetime string and attach timezone if provided. + If the string already carries a UTC offset (e.g. from _store_dt), return it + aware so Python can compare two aware datetimes directly. When the schedule + has no timezone and we would otherwise compare aware vs naive, strip the tz + so both sides stay naive-UTC.""" dt = datetime.fromisoformat(dt_str) - if tz and dt.tzinfo is None: - dt = dt.replace(tzinfo=tz) + if dt.tzinfo is None: + if tz: + dt = dt.replace(tzinfo=tz) + # else: remains naive UTC — matches _now() for no-tz schedules + elif tz is None: + # Stored with Z (UTC-aware) but schedule has no timezone — strip so + # comparison with naive _now() doesn't raise TypeError + dt = dt.replace(tzinfo=None) return dt def _strip_tz(self, dt): """Strip timezone for storage (timezone is persisted separately).""" return dt.replace(tzinfo=None) if dt.tzinfo else dt + def _store_dt(self, dt): + """Serialize a datetime to UTC ISO with Z suffix for next_check storage. + JavaScript Date() correctly converts UTC+Z to the browser's local timezone.""" + if dt.tzinfo is not None: + return dt.astimezone(ZoneInfo('UTC')).strftime('%Y-%m-%dT%H:%M:%SZ') + # Naive datetime — container runs UTC, so treat as UTC + return dt.isoformat() + 'Z' + # ── CRUD ────────────────────────────────────────────────────── def add_schedule(self, url, start_time, end_time, repeat=False, daily=False, name=None, resolution='1080p', framerate='any', format='mp4', timezone=None): @@ -186,10 +204,14 @@ def move_to_next_slot(self, browser_id): logger.info(f"Moving daily schedule {schedule_id} to next day") start_time_str = schedule['start_time'] start_hour, start_min = map(int, start_time_str.split(':')) - now = datetime.now() - tomorrow = now.date() + timedelta(days=1) - next_start = datetime.combine(tomorrow, datetime.min.time().replace(hour=start_hour, minute=start_min)) - schedule['next_check'] = next_start.isoformat() + tz = self._get_tz(schedule) + now_local = self._now(schedule) + tomorrow = now_local.date() + timedelta(days=1) + if tz: + next_start = datetime.combine(tomorrow, dtime(start_hour, start_min), tzinfo=tz) + else: + next_start = datetime.combine(tomorrow, dtime(start_hour, start_min)) + schedule['next_check'] = self._store_dt(next_start) logger.info(f"Daily schedule {schedule_id} moved to {next_start}") elif schedule.get('repeat'): logger.info(f"Moving weekly schedule {schedule_id} to next week") @@ -498,7 +520,7 @@ def _update_next_check(self, schedule): now = self._now(schedule) def _store(dt): - return self._strip_tz(dt).isoformat() + return self._store_dt(dt) if schedule.get('daily'): start_time_str = schedule['start_time'] From 0c9c0b6ff3c05fbac6dde747c6c4d2d32961b56c Mon Sep 17 00:00:00 2001 From: DorkSoul Date: Thu, 18 Jun 2026 11:43:38 +0100 Subject: [PATCH 5/8] fix timepicker not closing on outside click Stop click propagation on the popover div itself so any click that reaches the document listener is guaranteed to be outside the picker. Previously the contains() check could fail for detached SVG elements after clock redraws, leaving the popover stuck open. Co-Authored-By: Claude Sonnet 4.6 --- app/static/js/timepicker.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/static/js/timepicker.js b/app/static/js/timepicker.js index 4f49ba6..c8843c5 100644 --- a/app/static/js/timepicker.js +++ b/app/static/js/timepicker.js @@ -69,6 +69,7 @@ class TimePicker {
`; + pop.addEventListener('click', e => e.stopPropagation()); this._pop = pop; this.container.appendChild(pop); this._syncDigits(); @@ -98,7 +99,7 @@ class TimePicker { document.removeEventListener('click', this._outsideClick); } - _onOutside(e) { if (!this.container.contains(e.target)) this._close(); } + _onOutside(e) { this._close(); } _d(i) { return document.getElementById(`${this.containerId}__d${i}`); } From dfed5063345b210e975bb0f3cfacd2c3ed7cf9c2 Mon Sep 17 00:00:00 2001 From: DorkSoul Date: Thu, 18 Jun 2026 11:55:00 +0100 Subject: [PATCH 6/8] fix timepickers not closing each other when switching between them Removing stopPropagation from the display button lets the click bubble to the document, triggering any other open picker's outside-click listener. The setTimeout(0) delay on registering the listener already prevents the opening click from immediately re-closing the new picker. Co-Authored-By: Claude Sonnet 4.6 --- app/static/js/timepicker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/static/js/timepicker.js b/app/static/js/timepicker.js index c8843c5..ee633e4 100644 --- a/app/static/js/timepicker.js +++ b/app/static/js/timepicker.js @@ -37,7 +37,7 @@ class TimePicker { this._btn = document.createElement('button'); this._btn.type = 'button'; this._btn.className = 'tp-display'; - this._btn.addEventListener('click', e => { e.stopPropagation(); this._toggle(); }); + this._btn.addEventListener('click', () => this._toggle()); this.container.appendChild(this._btn); this._refreshBtn(); } From 9ec1a4f6427d0445bfc3304fcfb82fc9edc12930 Mon Sep 17 00:00:00 2001 From: DorkSoul Date: Fri, 19 Jun 2026 07:23:37 +0100 Subject: [PATCH 7/8] fix manual browser killing schedules and schedules stuck paused after restart Two bugs fixed: 1. Race condition (black screen): pause_all_for_manual was called after browser_service.start_browser, so queued scheduled checks could still fire _ensure_chrome_closed and kill the user's just-started browser. Now the pause runs BEFORE the browser starts, and the browser service queue processor drops in-flight scheduled launches immediately via the new _manual_active flag. 2. Schedules stuck paused after abnormal close: the old code wrote paused:True to disk, so if the tab was closed without hitting the close button the schedules stayed paused across restarts. Replaced with an auto_paused flag that is never persisted; load_schedules strips it on startup, guaranteeing a clean state after any restart. Co-Authored-By: Claude Sonnet 4.6 --- app/routes/browser_routes.py | 13 +++++++++++-- app/routes/download_routes.py | 15 ++++++++++----- app/scheduler.py | 30 +++++++++++++++--------------- app/services/browser_service.py | 17 +++++++++++++++++ 4 files changed, 53 insertions(+), 22 deletions(-) diff --git a/app/routes/browser_routes.py b/app/routes/browser_routes.py index 689b34b..b7ca677 100644 --- a/app/routes/browser_routes.py +++ b/app/routes/browser_routes.py @@ -34,14 +34,18 @@ def start_browser(): # Generate browser ID browser_id = f"browser_{int(time.time())}" + # Block scheduled checks BEFORE starting so the browser service + # queue processor drops in-flight scheduled launches immediately + if scheduler: + scheduler.pause_all_for_manual(browser_id) + browser_service.set_manual_active(True) + # Start browser success, detector = browser_service.start_browser( url, browser_id, resolution, framerate, auto_download, filename, output_format ) if success: - if scheduler: - scheduler.pause_all_for_manual(browser_id) return jsonify({ 'success': True, 'browser_id': browser_id, @@ -49,6 +53,10 @@ def start_browser(): 'vnc_url': f'/vnc' }) else: + # Roll back manual lock on failure + if scheduler: + scheduler.resume_after_manual(browser_id) + browser_service.set_manual_active(False) return jsonify({'error': 'Failed to start browser'}), 500 except Exception as e: @@ -88,6 +96,7 @@ def close_browser(browser_id): """Close browser manually""" try: if browser_service.close_browser(browser_id): + browser_service.set_manual_active(False) if scheduler: scheduler.resume_after_manual(browser_id) return jsonify({'success': True, 'message': 'Browser closed'}) diff --git a/app/routes/download_routes.py b/app/routes/download_routes.py index 19c86b5..fe58f5b 100644 --- a/app/routes/download_routes.py +++ b/app/routes/download_routes.py @@ -144,16 +144,20 @@ def download_direct(): default_filename = f"{timestamp_str}.mp4" filename = data.get('filename', default_filename) + browser_id = f"direct_{timestamp}" + + # Block scheduled checks before starting the download + if scheduler: + scheduler.pause_all_for_manual(browser_id) + scheduler.browser_service.set_manual_active(True) + # Start download - browser_id, output_path = download_service.start_direct_download( - f"direct_{timestamp}", + _, output_path = download_service.start_direct_download( + browser_id, stream_url, filename ) - if scheduler: - scheduler.pause_all_for_manual(browser_id) - return jsonify({ 'success': True, 'browser_id': browser_id, @@ -249,6 +253,7 @@ def stop_download(browser_id): scheduler.move_to_next_slot(browser_id) logger.info(f"Moved scheduled download {browser_id} to next time slot") else: + scheduler.browser_service.set_manual_active(False) scheduler.resume_after_manual(browser_id) return jsonify({'success': True, 'message': 'Download stopped'}) diff --git a/app/scheduler.py b/app/scheduler.py index 37aedff..35fbd3c 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -45,6 +45,9 @@ def load_schedules(self): # Ensure all schedules have next_check calculated for schedule in self.schedules: + # Clear in-memory-only auto_paused flag that may have been + # left over if a previous session ended without calling close + schedule.pop('auto_paused', None) if not schedule.get('next_check'): self._update_next_check(schedule) @@ -248,34 +251,31 @@ def pause_schedule(self, schedule_id): return None def pause_all_for_manual(self, browser_id): - """Pause all non-paused schedules while a manual download is active.""" + """Mark all active schedules as auto_paused (in memory only — never persisted). + Called BEFORE the manual browser starts so the scheduler stops queuing checks.""" paused_ids = [] with self.lock: for schedule in self.schedules: - if not schedule.get('paused', False): - schedule['paused'] = True - schedule['status'] = 'paused' + if not schedule.get('paused', False) and not schedule.get('auto_paused', False): + schedule['auto_paused'] = True paused_ids.append(schedule['id']) if paused_ids: self._auto_paused[browser_id] = paused_ids - self._mark_dirty() - self.save_schedules() if paused_ids: logger.info(f"Auto-paused {len(paused_ids)} schedule(s) for manual session {browser_id}") def resume_after_manual(self, browser_id): - """Resume schedules that were auto-paused for a manual session.""" + """Lift auto_paused flag for schedules held for a manual session.""" ids_to_resume = self._auto_paused.pop(browser_id, []) if not ids_to_resume: return with self.lock: for schedule in self.schedules: - if schedule['id'] in ids_to_resume: - schedule['paused'] = False - schedule['status'] = 'pending' - self._update_next_check(schedule) - self._mark_dirty() - self.save_schedules() + if schedule['id'] in ids_to_resume and schedule.get('auto_paused'): + del schedule['auto_paused'] + if not schedule.get('paused', False): + schedule['status'] = 'pending' + self._update_next_check(schedule) logger.info(f"Auto-resumed {len(ids_to_resume)} schedule(s) after manual session {browser_id}") def get_schedules(self): @@ -349,8 +349,8 @@ def _check_schedules(self): """Check all schedules and run tasks if needed""" with self.lock: for schedule in self.schedules: - # Skip paused schedules entirely - if schedule.get('paused', False): + # Skip paused schedules (user-paused or auto-paused for manual session) + if schedule.get('paused', False) or schedule.get('auto_paused', False): continue if schedule['status'] == 'completed' and not schedule.get('daily'): diff --git a/app/services/browser_service.py b/app/services/browser_service.py index 98bd6a6..cc2f8e1 100644 --- a/app/services/browser_service.py +++ b/app/services/browser_service.py @@ -24,6 +24,8 @@ def __init__(self, config, download_service): self.queue_running = False self.queue_lock = threading.Lock() + self._manual_active = False # set True while a manual session is running + # Start queue processor self._start_queue_processor() @@ -79,6 +81,11 @@ def _start_queue_processor(self): self.queue_processor_thread.start() logger.info("Browser queue processor started") + def set_manual_active(self, active: bool): + """Signal whether a manual browser/download session is in progress.""" + self._manual_active = active + logger.info(f"Manual session {'started' if active else 'ended'}") + def _process_browser_queue(self): """Background thread that processes browser launch requests one by one""" logger.info("Browser queue processor thread running") @@ -93,6 +100,16 @@ def _process_browser_queue(self): browser_id = request['browser_id'] + # Drop queued scheduled launches while a manual session is active + if self._manual_active and browser_id.startswith('sched_'): + logger.info(f"Dropping queued scheduled launch {browser_id} — manual session active") + with self.queue_lock: + self.active_browsers.pop(browser_id, None) + request['result']['success'] = False + request['completion_event'].set() + self.browser_queue.task_done() + continue + # Update status from 'queued' to 'launching' with self.queue_lock: if browser_id in self.active_browsers and isinstance(self.active_browsers[browser_id], dict): From da71197b161dc3fed180d77a1b11a8ff8ef8cafa Mon Sep 17 00:00:00 2001 From: DorkSoul Date: Fri, 19 Jun 2026 07:39:43 +0100 Subject: [PATCH 8/8] portal timepicker popover to body to escape modal stacking context Co-Authored-By: Claude Sonnet 4.6 --- app/static/css/style.css | 7 ++----- app/static/js/timepicker.js | 28 ++++++++++++++++++---------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/app/static/css/style.css b/app/static/css/style.css index 57db540..8ad7da4 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -613,15 +613,12 @@ select option { } .tp-popover { - position: absolute; - top: calc(100% + 6px); - left: 50%; - transform: translateX(-50%); + position: fixed; background: #2d2d44; border: 1px solid #4a4a6a; border-radius: 12px; padding: 14px 16px 16px; - z-index: 9999; + z-index: 99999; box-shadow: 0 12px 40px rgba(0,0,0,0.7); min-width: 252px; max-width: calc(100vw - 16px); diff --git a/app/static/js/timepicker.js b/app/static/js/timepicker.js index ee633e4..51456cb 100644 --- a/app/static/js/timepicker.js +++ b/app/static/js/timepicker.js @@ -71,7 +71,7 @@ class TimePicker {
`; pop.addEventListener('click', e => e.stopPropagation()); this._pop = pop; - this.container.appendChild(pop); + document.body.appendChild(pop); this._syncDigits(); this._hookDigits(); this._hookModes(); @@ -82,16 +82,24 @@ class TimePicker { _reposition() { if (!this._pop) return; - const rect = this._pop.getBoundingClientRect(); + const btnRect = this._btn.getBoundingClientRect(); + const popW = this._pop.offsetWidth; + const popH = this._pop.offsetHeight; const vw = window.innerWidth; - if (rect.right > vw - 8) { - this._pop.style.left = 'auto'; - this._pop.style.right = '0'; - this._pop.style.transform = 'none'; - } else if (rect.left < 8) { - this._pop.style.left = '0'; - this._pop.style.transform = 'none'; - } + const vh = window.innerHeight; + + let top = btnRect.bottom + 6; + let left = btnRect.left + btnRect.width / 2 - popW / 2; + + // Clamp horizontally + if (left + popW > vw - 8) left = vw - popW - 8; + if (left < 8) left = 8; + + // Flip above if not enough room below + if (top + popH > vh - 8) top = btnRect.top - popH - 6; + + this._pop.style.top = top + 'px'; + this._pop.style.left = left + 'px'; } _close() {