From 587cb0bcb41a9f8e38f9211322fb6251d282db74 Mon Sep 17 00:00:00 2001 From: ack Date: Sun, 19 Apr 2026 22:59:40 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=B0=D0=BC=D0=B0=D0=BB?= =?UTF-8?q?=D0=B0=D1=8C=20=D1=81=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B9=20=D0=BB?= =?UTF-8?q?=D0=BE=D0=B3=D0=B8=D0=BA=D0=BE=D0=B9,=20=D1=87=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- camlaps/forms.py | 4 +++ camlaps/services/timelapse_worker.py | 47 ++++++++++++++++++++++++++-- camlaps/views.py | 5 +++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/camlaps/forms.py b/camlaps/forms.py index aab2671..4f63c57 100644 --- a/camlaps/forms.py +++ b/camlaps/forms.py @@ -7,10 +7,14 @@ from .models import TimelapseJob class TimelapseJobCreateForm(forms.ModelForm): + """Форма создания задания таймлапса из UI.""" + def __init__(self, *args, **kwargs): + """Подставляет дефолтный период: последние 7 дней до текущей даты.""" super().__init__(*args, **kwargs) if not self.is_bound: today = timezone.localdate() + # Период по умолчанию: неделя до текущего дня включительно. self.initial.setdefault('date_to', today) self.initial.setdefault('date_from', today - timedelta(days=7)) diff --git a/camlaps/services/timelapse_worker.py b/camlaps/services/timelapse_worker.py index 157d19a..59b5245 100644 --- a/camlaps/services/timelapse_worker.py +++ b/camlaps/services/timelapse_worker.py @@ -13,6 +13,7 @@ logger = logging.getLogger('camlaps') def _iter_dates(date_from: date, date_to: date): + """Итерируется по датам включительно в указанном диапазоне.""" current = date_from while current <= date_to: yield current @@ -20,6 +21,7 @@ def _iter_dates(date_from: date, date_to: date): def _safe_camera_root(job: TimelapseJob) -> Path: + """Возвращает безопасный путь к каталогу камеры внутри STORAGE_PATH.""" storage_root = Path(settings.STORAGE_PATH).resolve() camera_root = (storage_root / job.camera.storage_path).resolve() @@ -38,6 +40,7 @@ def _time_to_minutes(t: time) -> int: def _pick_nearest_frame_for_day(job: TimelapseJob, day_files: list[Path]) -> Path | None: + """Выбирает кадр дня, ближайший к anchor_time с учетом фильтра дня/ночи.""" candidates: list[tuple[int, Path]] = [] anchor_minutes = _time_to_minutes(job.anchor_time) @@ -64,16 +67,46 @@ def _pick_nearest_frame_for_day(job: TimelapseJob, day_files: list[Path]) -> Pat def _select_frames(job: TimelapseJob, camera_root: Path) -> tuple[list[Path], int, int]: + """Подбирает кадры и возвращает (кадры, всего_дней, дней_с_кадрами).""" logger.info('worker:select_frames:start job_id=%s', job.id) selected: list[Path] = [] + days_total = 0 + days_with_frames = 0 + + # Для 1 кадра/сутки берем ближайший кадр к якорному времени в каждом дне. + if int(job.sampling_interval_minutes) == 1440: + for day in _iter_dates(job.date_from, job.date_to): + days_total += 1 + day_dir = camera_root / day.isoformat() + if not day_dir.exists() or not day_dir.is_dir(): + continue + + day_files = sorted(day_dir.glob('*.jpg')) + picked = _pick_nearest_frame_for_day(job, day_files) + if picked is not None: + selected.append(picked) + days_with_frames += 1 + + logger.info( + 'worker:select_frames:done job_id=%s selected=%s days_total=%s days_with_frames=%s mode=daily_anchor', + job.id, + len(selected), + days_total, + days_with_frames, + ) + return selected, days_total, days_with_frames + + # Для остальных интервалов применяем шаг по времени по общей временной оси. interval_seconds = int(job.sampling_interval_minutes) * 60 last_selected_dt: datetime | None = None for day in _iter_dates(job.date_from, job.date_to): + days_total += 1 day_dir = camera_root / day.isoformat() if not day_dir.exists() or not day_dir.is_dir(): continue + day_has_selected = False day_files = sorted(day_dir.glob('*.jpg')) for img_path in day_files: if img_path.name.lower() == 'lastsnap.jpg': @@ -91,9 +124,19 @@ def _select_frames(job: TimelapseJob, camera_root: Path) -> tuple[list[Path], in if last_selected_dt is None or (current_dt - last_selected_dt).total_seconds() >= interval_seconds: selected.append(img_path) last_selected_dt = current_dt + day_has_selected = True - logger.info('worker:select_frames:done job_id=%s selected=%s', job.id, len(selected)) - return selected + if day_has_selected: + days_with_frames += 1 + + logger.info( + 'worker:select_frames:done job_id=%s selected=%s days_total=%s days_with_frames=%s mode=interval', + job.id, + len(selected), + days_total, + days_with_frames, + ) + return selected, days_total, days_with_frames def _copy_frames_to_temp(job: TimelapseJob, frame_paths: list[Path], temp_dir: Path): diff --git a/camlaps/views.py b/camlaps/views.py index 3967cad..fe1589e 100644 --- a/camlaps/views.py +++ b/camlaps/views.py @@ -59,6 +59,7 @@ def camera_preview(request, camera_id: int): def job_list(request): + """Отображает таблицу задач и признак активности очереди для автообновления.""" qs = TimelapseJob.objects.select_related('camera').all() camera_id = request.GET.get('camera') if camera_id: @@ -78,6 +79,7 @@ def job_list(request): def worker_logs(request): + """Показывает последние строки лога воркера в интерфейсе.""" logger.info('logs:view:start') log_file = settings.LOG_DIR / 'camlaps.log' lines = [] @@ -106,6 +108,7 @@ def clear_worker_logs(request): @require_POST def start_queue(request): + """Запускает воркер одним проходом для следующей задачи из очереди.""" logger.info('queue:start_request:start') has_planned = TimelapseJob.objects.filter(status=TimelapseJob.Status.PLANNED).exists() @@ -135,6 +138,7 @@ def start_queue(request): @require_POST def retry_job(request, job_id: int): + """Сбрасывает задачу в planned для повторного запуска после ошибки.""" logger.info('job:retry:start job_id=%s', job_id) job = get_object_or_404(TimelapseJob, pk=job_id) @@ -194,6 +198,7 @@ def job_detail(request, job_id: int): @require_POST def run_job_now(request, job_id: int): + """Немедленно запускает конкретную задачу через management command.""" logger.info('job:run_now:start job_id=%s', job_id) job = get_object_or_404(TimelapseJob, pk=job_id)