добавил обработчик задач
All checks were successful
Deploy timelaps / deploy (push) Successful in 5s
All checks were successful
Deploy timelaps / deploy (push) Successful in 5s
This commit is contained in:
0
camlaps/management/commands/__init__.py
Normal file
0
camlaps/management/commands/__init__.py
Normal file
31
camlaps/management/commands/run_timelapse_worker.py
Normal file
31
camlaps/management/commands/run_timelapse_worker.py
Normal file
@@ -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)
|
||||||
0
camlaps/services/_ init _.py
Normal file
0
camlaps/services/_ init _.py
Normal file
@@ -2,6 +2,7 @@ import logging
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
from ..models import Camera
|
from ..models import Camera
|
||||||
|
|
||||||
@@ -16,6 +17,19 @@ def _storage_root() -> Path:
|
|||||||
return Path(storage_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:
|
def is_storage_available() -> bool:
|
||||||
logger.info('cameras:storage_available:start')
|
logger.info('cameras:storage_available:start')
|
||||||
try:
|
try:
|
||||||
@@ -58,3 +72,52 @@ def get_camera_lastsnap_path(camera: Camera) -> Path | None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('cameras:lastsnap_path:error camera_id=%s', camera.id)
|
logger.exception('cameras:lastsnap_path:error camera_id=%s', camera.id)
|
||||||
raise
|
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
|
||||||
213
camlaps/services/timelapse_worker.py
Normal file
213
camlaps/services/timelapse_worker.py
Normal file
@@ -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
|
||||||
@@ -3,13 +3,51 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2>Твои камеры</h2>
|
<h2>Твои камеры</h2>
|
||||||
|
<div class="d-flex gap-2 align-items-center">
|
||||||
{% if storage_available %}
|
{% if storage_available %}
|
||||||
<span class="badge bg-success">Storage доступен</span>
|
<span class="badge bg-success">Storage доступен</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-danger">Storage недоступен</span>
|
<span class="badge bg-danger">Storage недоступен</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<form method="post" action="{% url 'camlaps:discover_cameras' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-primary">Найти камеры</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if added is not None %}
|
||||||
|
<div class="alert alert-success py-2">
|
||||||
|
Добавление завершено: добавлено новых камер — {{ added }}.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if discovered_candidates is not None %}
|
||||||
|
<div class="card border-info mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Найдено новых камер: {{ discovered_count }}</h5>
|
||||||
|
{% if discovered_count %}
|
||||||
|
<form method="post" action="{% url 'camlaps:apply_discovered_cameras' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
{% for c in discovered_candidates %}
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-check-label d-flex align-items-center gap-2">
|
||||||
|
<input class="form-check-input" type="checkbox" name="selected_storage_paths" value="{{ c.storage_path }}" checked>
|
||||||
|
<span>{{ c.name }} <span class="text-muted">({{ c.storage_path }})</span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Добавить выбранные камеры</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted">Новых папок камер не найдено.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="row row-cols-1 row-cols-md-3 g-4">
|
<div class="row row-cols-1 row-cols-md-3 g-4">
|
||||||
{% for camera in cameras %}
|
{% for camera in cameras %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
|||||||
@@ -29,6 +29,15 @@
|
|||||||
<div class="text-muted">FPS</div>
|
<div class="text-muted">FPS</div>
|
||||||
<div class="fw-semibold">{{ job.fps }}</div>
|
<div class="fw-semibold">{{ job.fps }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="text-muted">Кадры</div>
|
||||||
|
<div class="fw-semibold">
|
||||||
|
{{ job.frames_processed }}
|
||||||
|
{% if job.frames_total %}
|
||||||
|
/ {{ job.frames_total }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="text-muted">Ночь</div>
|
<div class="text-muted">Ночь</div>
|
||||||
<div class="fw-semibold">
|
<div class="fw-semibold">
|
||||||
|
|||||||
@@ -3,8 +3,22 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2>Очередь задач</h2>
|
<h2>Очередь задач</h2>
|
||||||
|
<form method="post" action="{% url 'camlaps:start_queue' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Старт очереди</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if queue_started == 'none' %}
|
||||||
|
<div class="alert alert-warning py-2">Очередь пуста: задач со статусом «Запланировано» нет.</div>
|
||||||
|
{% elif queue_started == 'worker' %}
|
||||||
|
<div class="alert alert-success py-2">Воркер очереди запущен. Обнови страницу через несколько секунд.</div>
|
||||||
|
{% elif queue_started == 'error' %}
|
||||||
|
<div class="alert alert-danger py-2">Не удалось запустить воркер. Проверь логи Django.</div>
|
||||||
|
{% elif queue_started %}
|
||||||
|
<div class="alert alert-success py-2">Запущена задача #{{ queue_started }}.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped align-middle">
|
<table class="table table-striped align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ app_name = 'camlaps'
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.index, name='index'),
|
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/<int:camera_id>/preview/', views.camera_preview, name='camera_preview'),
|
path('cameras/<int:camera_id>/preview/', views.camera_preview, name='camera_preview'),
|
||||||
path('cameras/<int:camera_id>/jobs/new/', views.job_create, name='job_create'),
|
path('cameras/<int:camera_id>/jobs/new/', views.job_create, name='job_create'),
|
||||||
path('jobs/', views.job_list, name='job_list'),
|
path('jobs/', views.job_list, name='job_list'),
|
||||||
|
path('jobs/start/', views.start_queue, name='start_queue'),
|
||||||
path('jobs/<int:job_id>/', views.job_detail, name='job_detail'),
|
path('jobs/<int:job_id>/', views.job_detail, name='job_detail'),
|
||||||
]
|
]
|
||||||
@@ -1,9 +1,20 @@
|
|||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.http import FileResponse, Http404
|
from django.http import FileResponse, Http404
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from .models import Camera, TimelapseJob
|
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):
|
def index(request):
|
||||||
@@ -11,10 +22,31 @@ def index(request):
|
|||||||
context = {
|
context = {
|
||||||
'cameras': cameras,
|
'cameras': cameras,
|
||||||
'storage_available': is_storage_available(),
|
'storage_available': is_storage_available(),
|
||||||
|
'added': request.GET.get('added'),
|
||||||
}
|
}
|
||||||
return render(request, 'camlaps/index.html', context)
|
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):
|
def camera_preview(request, camera_id: int):
|
||||||
camera = get_object_or_404(Camera, pk=camera_id, is_active=True)
|
camera = get_object_or_404(Camera, pk=camera_id, is_active=True)
|
||||||
path = get_camera_lastsnap_path(camera)
|
path = get_camera_lastsnap_path(camera)
|
||||||
@@ -29,7 +61,31 @@ def job_list(request):
|
|||||||
if camera_id:
|
if camera_id:
|
||||||
qs = qs.filter(camera_id=camera_id)
|
qs = qs.filter(camera_id=camera_id)
|
||||||
jobs = qs.order_by('-created_at')[:200]
|
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):
|
def job_detail(request, job_id: int):
|
||||||
|
|||||||
Reference in New Issue
Block a user