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..b7ca677 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']) @@ -34,6 +34,12 @@ 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 @@ -47,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: @@ -86,6 +96,9 @@ 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'}) else: return jsonify({'error': 'Browser not found'}), 404 diff --git a/app/routes/download_routes.py b/app/routes/download_routes.py index daa8f1d..fe58f5b 100644 --- a/app/routes/download_routes.py +++ b/app/routes/download_routes.py @@ -144,9 +144,16 @@ 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 ) @@ -241,10 +248,13 @@ 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.browser_service.set_manual_active(False) + scheduler.resume_after_manual(browser_id) return jsonify({'success': True, 'message': 'Download stopped'}) else: 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 e166b26..35fbd3c 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__) @@ -30,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() @@ -43,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) @@ -67,7 +72,55 @@ 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. + 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 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): """Add a new schedule""" with self.lock: schedule = { @@ -81,6 +134,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 +154,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 +168,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() @@ -151,10 +207,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") @@ -190,6 +250,34 @@ def pause_schedule(self, schedule_id): return schedule return None + def pause_all_for_manual(self, browser_id): + """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) 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 + 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): + """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 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): """Get all schedules — active schedules sorted by next_check, paused schedules at the bottom.""" sorted_schedules = sorted( @@ -259,12 +347,10 @@ 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 - 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'): @@ -275,11 +361,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']: @@ -300,12 +390,13 @@ 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') 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): @@ -314,7 +405,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: @@ -324,8 +415,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) @@ -347,21 +437,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) @@ -374,12 +468,13 @@ 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') 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): @@ -388,7 +483,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: @@ -398,8 +493,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) @@ -422,7 +516,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._store_dt(dt) if schedule.get('daily'): start_time_str = schedule['start_time'] @@ -432,48 +530,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 @@ -501,25 +601,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 +697,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 +718,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: 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): diff --git a/app/static/css/style.css b/app/static/css/style.css index ed3bea6..8ad7da4 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -575,3 +575,138 @@ 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: fixed; + background: #2d2d44; + border: 1px solid #4a4a6a; + border-radius: 12px; + padding: 14px 16px 16px; + z-index: 99999; + 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 { + 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 = ` -
Window: ${windowText}
Repeat: ${repeatText}
-Status: ${statusText}
+Status: ${statusText}