From 4a10958445c4454235bfa3849a68d0576ca1821a Mon Sep 17 00:00:00 2001 From: ack Date: Sun, 19 Apr 2026 19:41:33 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=87=D0=B8=D0=BA=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B4=D0=B0=D1=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_ init _.py => management/__init__.py} | 0 camlaps/management/commands/__init__.py | 0 .../commands/run_timelapse_worker.py | 31 +++ camlaps/services/_ init _.py | 0 camlaps/services/cameras.py | 63 ++++++ camlaps/services/timelapse_worker.py | 213 ++++++++++++++++++ camlaps/templates/camlaps/index.html | 48 +++- camlaps/templates/camlaps/job_detail.html | 9 + camlaps/templates/camlaps/job_list.html | 14 ++ camlaps/urls.py | 3 + camlaps/views.py | 60 ++++- 11 files changed, 434 insertions(+), 7 deletions(-) rename camlaps/{camlaps/services/_ init _.py => management/__init__.py} (100%) create mode 100644 camlaps/management/commands/__init__.py create mode 100644 camlaps/management/commands/run_timelapse_worker.py create mode 100644 camlaps/services/_ init _.py create mode 100644 camlaps/services/timelapse_worker.py diff --git a/camlaps/camlaps/services/_ init _.py b/camlaps/management/__init__.py similarity index 100% rename from camlaps/camlaps/services/_ init _.py rename to camlaps/management/__init__.py diff --git a/camlaps/management/commands/__init__.py b/camlaps/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/camlaps/management/commands/run_timelapse_worker.py b/camlaps/management/commands/run_timelapse_worker.py new file mode 100644 index 0000000..5374c3b --- /dev/null +++ b/camlaps/management/commands/run_timelapse_worker.py @@ -0,0 +1,31 @@ +import time + +from django.core.management.base import BaseCommand + +from ...services.timelapse_worker import run_one_job + + +class Command(BaseCommand): + help = 'Запуск обработчика очереди таймлапсов.' + + def add_arguments(self, parser): + parser.add_argument('--once', action='store_true', help='Обработать только одну задачу и выйти.') + parser.add_argument( + '--sleep', + type=int, + default=5, + help='Пауза между итерациями в loop-режиме (секунды).', + ) + + def handle(self, *args, **options): + once = options['once'] + sleep_seconds = options['sleep'] + + if once: + run_one_job() + return + + while True: + processed = run_one_job() + if not processed: + time.sleep(sleep_seconds) \ No newline at end of file diff --git a/camlaps/services/_ init _.py b/camlaps/services/_ init _.py new file mode 100644 index 0000000..e69de29 diff --git a/camlaps/services/cameras.py b/camlaps/services/cameras.py index 0364cf1..addd6b2 100644 --- a/camlaps/services/cameras.py +++ b/camlaps/services/cameras.py @@ -2,6 +2,7 @@ import logging from pathlib import Path from django.conf import settings +from django.utils.text import slugify from ..models import Camera @@ -16,6 +17,19 @@ def _storage_root() -> Path: return Path(storage_path) +def generate_unique_camera_slug(directory_name: str, storage_path: str) -> str: + base_slug = (slugify(directory_name) or f'camera-{directory_name.lower()}')[:70] + slug = base_slug + suffix = 1 + + while Camera.objects.filter(slug=slug).exclude(storage_path=storage_path).exists(): + suffix_part = f'-{suffix}' + slug = f"{base_slug[:80 - len(suffix_part)]}{suffix_part}" + suffix += 1 + + return slug + + def is_storage_available() -> bool: logger.info('cameras:storage_available:start') try: @@ -57,4 +71,53 @@ def get_camera_lastsnap_path(camera: Camera) -> Path | None: return candidate except Exception: logger.exception('cameras:lastsnap_path:error camera_id=%s', camera.id) + raise + + +def discover_camera_candidates() -> list[dict[str, str]]: + logger.info('cameras:discover_candidates:start') + try: + root = _storage_root() + if not root.exists() or not root.is_dir(): + logger.info('cameras:discover_candidates:done count=0') + return [] + + existing_paths = set(Camera.objects.values_list('storage_path', flat=True)) + dirs = [p for p in root.iterdir() if p.is_dir() and not p.name.startswith('.') and p.name != 'timelapses'] + + candidates = [] + for directory in sorted(dirs, key=lambda d: d.name.lower()): + storage_path = directory.name + if storage_path in existing_paths: + continue + candidates.append({'name': directory.name, 'storage_path': storage_path}) + + logger.info('cameras:discover_candidates:done count=%s', len(candidates)) + return candidates + except Exception: + logger.exception('cameras:discover_candidates:error') + raise + + +def create_cameras_from_candidates(selected_storage_paths: list[str]) -> int: + logger.info('cameras:create_from_candidates:start count=%s', len(selected_storage_paths)) + try: + created = 0 + for storage_path in selected_storage_paths: + path = storage_path.strip() + if not path: + continue + + slug = generate_unique_camera_slug(path, path) + _, was_created = Camera.objects.get_or_create( + storage_path=path, + defaults={'name': path, 'slug': slug, 'is_active': True}, + ) + if was_created: + created += 1 + + logger.info('cameras:create_from_candidates:done created=%s', created) + return created + except Exception: + logger.exception('cameras:create_from_candidates:error') raise \ No newline at end of file diff --git a/camlaps/services/timelapse_worker.py b/camlaps/services/timelapse_worker.py new file mode 100644 index 0000000..e4a4008 --- /dev/null +++ b/camlaps/services/timelapse_worker.py @@ -0,0 +1,213 @@ +import logging +import shutil +import subprocess +from datetime import date, datetime, time, timedelta +from pathlib import Path + +from django.conf import settings +from django.utils import timezone + +from ..models import TimelapseJob + +logger = logging.getLogger('camlaps') + + +def _iter_dates(date_from: date, date_to: date): + current = date_from + while current <= date_to: + yield current + current += timedelta(days=1) + + +def _safe_camera_root(job: TimelapseJob) -> Path: + storage_root = Path(settings.STORAGE_PATH).resolve() + camera_root = (storage_root / job.camera.storage_path).resolve() + + if camera_root != storage_root and storage_root not in camera_root.parents: + raise RuntimeError('Некорректный путь камеры.') + + return camera_root + + +def _is_day_time(snapshot_time: time, day_start: time, day_end: time) -> bool: + return day_start <= snapshot_time <= day_end + + +def _select_frames(job: TimelapseJob, camera_root: Path) -> list[Path]: + logger.info('worker:select_frames:start job_id=%s', job.id) + selected: list[Path] = [] + 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): + 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')) + for img_path in day_files: + if img_path.name.lower() == 'lastsnap.jpg': + continue + + try: + snap_time = datetime.strptime(img_path.stem, '%H-%M-%S').time() + except ValueError: + continue + + if not job.include_night and not _is_day_time(snap_time, job.day_start_time, job.day_end_time): + continue + + current_dt = datetime.combine(day, snap_time) + 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 + + logger.info('worker:select_frames:done job_id=%s selected=%s', job.id, len(selected)) + return selected + + +def _copy_frames_to_temp(job: TimelapseJob, frame_paths: list[Path], temp_dir: Path): + logger.info('worker:copy_frames:start job_id=%s count=%s', job.id, len(frame_paths)) + temp_dir.mkdir(parents=True, exist_ok=True) + + total = len(frame_paths) + for index, src in enumerate(frame_paths, start=1): + dst = temp_dir / f'{index:06d}.jpg' + shutil.copy2(src, dst) + + progress = min(70, int((index / total) * 70)) + TimelapseJob.objects.filter(pk=job.pk).update( + frames_processed=index, + progress_percent=progress, + ) + + logger.info('worker:copy_frames:done job_id=%s', job.id) + + +def _build_output_path(job: TimelapseJob) -> tuple[Path, str]: + export_dir = Path(settings.TIMELAPS_EXPORT_DIR) + export_dir.mkdir(parents=True, exist_ok=True) + + filename = ( + f'{job.camera.slug}_{job.date_from.strftime("%Y%m%d")}_' + f'{job.date_to.strftime("%Y%m%d")}_{job.id}.mp4' + ) + output_path = export_dir / filename + rel_path = f'timelapses/{filename}' + return output_path, rel_path + + +def _run_ffmpeg(job: TimelapseJob, temp_dir: Path, output_path: Path): + logger.info('worker:ffmpeg:start job_id=%s', job.id) + + cmd = [ + 'ffmpeg', + '-y', + '-hide_banner', + '-loglevel', + 'error', + '-framerate', + str(job.fps), + '-i', + str(temp_dir / '%06d.jpg'), + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-pix_fmt', + 'yuv420p', + str(output_path), + ] + + subprocess.run(cmd, check=True) + logger.info('worker:ffmpeg:done job_id=%s', job.id) + + +def claim_next_job() -> TimelapseJob | None: + logger.info('worker:claim_next:start') + candidate = TimelapseJob.objects.filter(status=TimelapseJob.Status.PLANNED).order_by('created_at').first() + if not candidate: + logger.info('worker:claim_next:done no_jobs=true') + return None + + updated = TimelapseJob.objects.filter( + pk=candidate.pk, + status=TimelapseJob.Status.PLANNED, + ).update( + status=TimelapseJob.Status.RUNNING, + started_at=timezone.now(), + progress_percent=1, + frames_processed=0, + frames_total=None, + error_message='', + ) + + if not updated: + logger.info('worker:claim_next:done race_lost=true') + return None + + job = TimelapseJob.objects.select_related('camera').get(pk=candidate.pk) + logger.info('worker:claim_next:done job_id=%s', job.id) + return job + + +def process_job(job: TimelapseJob) -> bool: + logger.info('worker:process_job:start job_id=%s', job.id) + + temp_dir = Path(settings.TIMELAPS_EXPORT_DIR) / '_tmp' / f'job_{job.id}' + try: + if temp_dir.exists(): + shutil.rmtree(temp_dir, ignore_errors=True) + + camera_root = _safe_camera_root(job) + frame_paths = _select_frames(job, camera_root) + + if not frame_paths: + raise RuntimeError('Не найдено кадров под выбранные параметры.') + + TimelapseJob.objects.filter(pk=job.pk).update( + frames_total=len(frame_paths), + frames_processed=0, + progress_percent=5, + ) + + _copy_frames_to_temp(job, frame_paths, temp_dir) + output_path, rel_path = _build_output_path(job) + + TimelapseJob.objects.filter(pk=job.pk).update(progress_percent=90) + _run_ffmpeg(job, temp_dir, output_path) + + TimelapseJob.objects.filter(pk=job.pk).update( + status=TimelapseJob.Status.SUCCESS, + progress_percent=100, + output_rel_path=rel_path, + finished_at=timezone.now(), + error_message='', + frames_processed=len(frame_paths), + ) + logger.info('worker:process_job:done job_id=%s', job.id) + return True + + except Exception as exc: + logger.exception('worker:process_job:error job_id=%s', job.id) + TimelapseJob.objects.filter(pk=job.pk).update( + status=TimelapseJob.Status.ERROR, + finished_at=timezone.now(), + error_message=str(exc)[:1000], + ) + return False + finally: + if temp_dir.exists(): + shutil.rmtree(temp_dir, ignore_errors=True) + + +def run_one_job() -> bool: + logger.info('worker:run_one:start') + job = claim_next_job() + if not job: + logger.info('worker:run_one:done processed=false') + return False + + process_job(job) + logger.info('worker:run_one:done processed=true') + return True \ No newline at end of file diff --git a/camlaps/templates/camlaps/index.html b/camlaps/templates/camlaps/index.html index 054002d..24b15af 100644 --- a/camlaps/templates/camlaps/index.html +++ b/camlaps/templates/camlaps/index.html @@ -3,13 +3,51 @@ {% block content %}

Твои камеры

- {% if storage_available %} - Storage доступен - {% else %} - Storage недоступен - {% endif %} +
+ {% if storage_available %} + Storage доступен + {% else %} + Storage недоступен + {% endif %} +
+ {% csrf_token %} + +
+
+{% if added is not None %} +
+ Добавление завершено: добавлено новых камер — {{ added }}. +
+{% endif %} + +{% if discovered_candidates is not None %} +
+
+
Найдено новых камер: {{ discovered_count }}
+ {% if discovered_count %} +
+ {% csrf_token %} +
+ {% for c in discovered_candidates %} +
+ +
+ {% endfor %} +
+ +
+ {% else %} +
Новых папок камер не найдено.
+ {% endif %} +
+
+{% endif %} +
{% for camera in cameras %}
diff --git a/camlaps/templates/camlaps/job_detail.html b/camlaps/templates/camlaps/job_detail.html index fb51376..9c17196 100644 --- a/camlaps/templates/camlaps/job_detail.html +++ b/camlaps/templates/camlaps/job_detail.html @@ -29,6 +29,15 @@
FPS
{{ job.fps }}
+
+
Кадры
+
+ {{ job.frames_processed }} + {% if job.frames_total %} + / {{ job.frames_total }} + {% endif %} +
+
Ночь
diff --git a/camlaps/templates/camlaps/job_list.html b/camlaps/templates/camlaps/job_list.html index b7e424a..082748d 100644 --- a/camlaps/templates/camlaps/job_list.html +++ b/camlaps/templates/camlaps/job_list.html @@ -3,8 +3,22 @@ {% block content %}

Очередь задач

+
+ {% csrf_token %} + +
+{% if queue_started == 'none' %} +
Очередь пуста: задач со статусом «Запланировано» нет.
+{% elif queue_started == 'worker' %} +
Воркер очереди запущен. Обнови страницу через несколько секунд.
+{% elif queue_started == 'error' %} +
Не удалось запустить воркер. Проверь логи Django.
+{% elif queue_started %} +
Запущена задача #{{ queue_started }}.
+{% endif %} +
diff --git a/camlaps/urls.py b/camlaps/urls.py index 1983ba9..ab00e89 100644 --- a/camlaps/urls.py +++ b/camlaps/urls.py @@ -6,8 +6,11 @@ app_name = 'camlaps' urlpatterns = [ path('', views.index, name='index'), + path('cameras/discover/', views.discover_cameras, name='discover_cameras'), + path('cameras/discover/apply/', views.apply_discovered_cameras, name='apply_discovered_cameras'), path('cameras//preview/', views.camera_preview, name='camera_preview'), path('cameras//jobs/new/', views.job_create, name='job_create'), path('jobs/', views.job_list, name='job_list'), + path('jobs/start/', views.start_queue, name='start_queue'), path('jobs//', views.job_detail, name='job_detail'), ] \ No newline at end of file diff --git a/camlaps/views.py b/camlaps/views.py index 201ee9a..05d1adb 100644 --- a/camlaps/views.py +++ b/camlaps/views.py @@ -1,9 +1,20 @@ +import subprocess +import sys + +from django.conf import settings from django.http import FileResponse, Http404 from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django.views.decorators.http import require_POST from .models import Camera, TimelapseJob -from .services.cameras import get_camera_lastsnap_path, is_storage_available, list_active_cameras +from .services.cameras import ( + create_cameras_from_candidates, + discover_camera_candidates, + get_camera_lastsnap_path, + is_storage_available, + list_active_cameras, +) def index(request): @@ -11,10 +22,31 @@ def index(request): context = { 'cameras': cameras, 'storage_available': is_storage_available(), + 'added': request.GET.get('added'), } return render(request, 'camlaps/index.html', context) +@require_POST +def discover_cameras(request): + cameras = list_active_cameras() + candidates = discover_camera_candidates() + context = { + 'cameras': cameras, + 'storage_available': is_storage_available(), + 'discovered_candidates': candidates, + 'discovered_count': len(candidates), + } + return render(request, 'camlaps/index.html', context) + + +@require_POST +def apply_discovered_cameras(request): + selected_paths = request.POST.getlist('selected_storage_paths') + created = create_cameras_from_candidates(selected_paths) + return redirect(f"{reverse('camlaps:index')}?added={created}") + + def camera_preview(request, camera_id: int): camera = get_object_or_404(Camera, pk=camera_id, is_active=True) path = get_camera_lastsnap_path(camera) @@ -29,7 +61,31 @@ def job_list(request): if camera_id: qs = qs.filter(camera_id=camera_id) jobs = qs.order_by('-created_at')[:200] - return render(request, 'camlaps/job_list.html', {'jobs': jobs}) + return render(request, 'camlaps/job_list.html', {'jobs': jobs, 'queue_started': request.GET.get('started')}) + + +@require_POST +def start_queue(request): + has_planned = TimelapseJob.objects.filter(status=TimelapseJob.Status.PLANNED).exists() + if not has_planned: + return redirect(f"{reverse('camlaps:job_list')}?started=none") + + cmd = [sys.executable, 'manage.py', 'run_timelapse_worker', '--once'] + + kwargs = { + 'cwd': settings.BASE_DIR, + 'stdout': subprocess.DEVNULL, + 'stderr': subprocess.DEVNULL, + } + + if sys.platform.startswith('win'): + kwargs['creationflags'] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP + + try: + subprocess.Popen(cmd, **kwargs) + return redirect(f"{reverse('camlaps:job_list')}?started=worker") + except Exception: + return redirect(f"{reverse('camlaps:job_list')}?started=error") def job_detail(request, job_id: int):