251 lines
7.7 KiB
Python
251 lines
7.7 KiB
Python
import logging
|
|
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 (
|
|
create_cameras_from_candidates,
|
|
discover_camera_candidates,
|
|
get_camera_lastsnap_path,
|
|
is_storage_available,
|
|
list_active_cameras,
|
|
)
|
|
|
|
logger = logging.getLogger('camlaps')
|
|
|
|
|
|
def index(request):
|
|
cameras = list_active_cameras()
|
|
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)
|
|
if not path:
|
|
raise Http404
|
|
return FileResponse(path.open('rb'), content_type='image/jpeg')
|
|
|
|
|
|
def job_list(request):
|
|
qs = TimelapseJob.objects.select_related('camera').all()
|
|
camera_id = request.GET.get('camera')
|
|
if camera_id:
|
|
qs = qs.filter(camera_id=camera_id)
|
|
jobs = qs.order_by('-created_at')[:200]
|
|
has_active_jobs = qs.filter(status__in=[TimelapseJob.Status.PLANNED, TimelapseJob.Status.RUNNING]).exists()
|
|
return render(
|
|
request,
|
|
'camlaps/job_list.html',
|
|
{
|
|
'jobs': jobs,
|
|
'queue_started': request.GET.get('started'),
|
|
'retried': request.GET.get('retried'),
|
|
'has_active_jobs': has_active_jobs,
|
|
},
|
|
)
|
|
|
|
|
|
def worker_logs(request):
|
|
logger.info('logs:view:start')
|
|
log_file = settings.LOG_DIR / 'camlaps.log'
|
|
lines = []
|
|
|
|
if log_file.exists():
|
|
with log_file.open('r', encoding='utf-8', errors='replace') as f:
|
|
lines = f.readlines()[-300:]
|
|
|
|
logger.info('logs:view:done lines=%s', len(lines))
|
|
return render(request, 'camlaps/worker_logs.html', {'log_lines': ''.join(lines)})
|
|
|
|
|
|
@require_POST
|
|
def clear_worker_logs(request):
|
|
logger.info('logs:clear:start')
|
|
try:
|
|
log_file = settings.LOG_DIR / 'camlaps.log'
|
|
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
log_file.write_text('', encoding='utf-8')
|
|
logger.info('logs:clear:done')
|
|
return redirect(f"{reverse('camlaps:worker_logs')}?cleared=1")
|
|
except Exception:
|
|
logger.exception('logs:clear:error')
|
|
return redirect(f"{reverse('camlaps:worker_logs')}?cleared=0")
|
|
|
|
|
|
@require_POST
|
|
def start_queue(request):
|
|
logger.info('queue:start_request:start')
|
|
|
|
has_planned = TimelapseJob.objects.filter(status=TimelapseJob.Status.PLANNED).exists()
|
|
if not has_planned:
|
|
logger.info('queue:start_request:done no_planned_jobs=true')
|
|
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)
|
|
logger.info('queue:start_request:done worker_started=true')
|
|
return redirect(f"{reverse('camlaps:job_list')}?started=worker")
|
|
except Exception:
|
|
logger.exception('queue:start_request:error')
|
|
return redirect(f"{reverse('camlaps:job_list')}?started=error")
|
|
|
|
|
|
@require_POST
|
|
def retry_job(request, job_id: int):
|
|
logger.info('job:retry:start job_id=%s', job_id)
|
|
job = get_object_or_404(TimelapseJob, pk=job_id)
|
|
|
|
if job.status == TimelapseJob.Status.RUNNING:
|
|
logger.info('job:retry:done job_id=%s blocked=running', job_id)
|
|
return redirect(f"{reverse('camlaps:job_list')}?retried=running")
|
|
|
|
job.status = TimelapseJob.Status.PLANNED
|
|
job.progress_percent = 0
|
|
job.frames_total = None
|
|
job.frames_processed = 0
|
|
job.days_total = 0
|
|
job.days_with_frames = 0
|
|
job.days_skipped = 0
|
|
job.error_message = ''
|
|
job.started_at = None
|
|
job.finished_at = None
|
|
job.save(
|
|
update_fields=[
|
|
'status',
|
|
'progress_percent',
|
|
'frames_total',
|
|
'frames_processed',
|
|
'days_total',
|
|
'days_with_frames',
|
|
'days_skipped',
|
|
'error_message',
|
|
'started_at',
|
|
'finished_at',
|
|
]
|
|
)
|
|
|
|
logger.info('job:retry:done job_id=%s', job_id)
|
|
return redirect(f"{reverse('camlaps:job_list')}?retried={job_id}")
|
|
|
|
|
|
def job_detail(request, job_id: int):
|
|
job = get_object_or_404(TimelapseJob.objects.select_related('camera'), pk=job_id)
|
|
video_url = None
|
|
if job.output_rel_path:
|
|
rel = job.output_rel_path.lstrip('/')
|
|
if rel.startswith('timelapses/'):
|
|
video_url = '/' + rel
|
|
else:
|
|
video_url = '/timelapses/' + rel.split('/')[-1]
|
|
|
|
return render(
|
|
request,
|
|
'camlaps/job_detail.html',
|
|
{
|
|
'job': job,
|
|
'video_url': video_url,
|
|
'run_now': request.GET.get('run_now'),
|
|
},
|
|
)
|
|
|
|
|
|
@require_POST
|
|
def run_job_now(request, job_id: int):
|
|
logger.info('job:run_now:start job_id=%s', job_id)
|
|
job = get_object_or_404(TimelapseJob, pk=job_id)
|
|
|
|
if job.status == TimelapseJob.Status.RUNNING:
|
|
logger.info('job:run_now:done job_id=%s blocked=running', job_id)
|
|
return redirect(f"{reverse('camlaps:job_list')}?started=running")
|
|
|
|
cmd = [sys.executable, 'manage.py', 'run_timelapse_worker', '--job-id', str(job_id)]
|
|
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)
|
|
logger.info('job:run_now:done job_id=%s worker_started=true', job_id)
|
|
return redirect(f"{reverse('camlaps:job_list')}?started={job_id}")
|
|
except Exception:
|
|
logger.exception('job:run_now:error job_id=%s', job_id)
|
|
return redirect(f"{reverse('camlaps:job_list')}?started=error")
|
|
|
|
|
|
def job_create(request, camera_id: int):
|
|
from .forms import TimelapseJobCreateForm
|
|
|
|
camera = get_object_or_404(Camera, pk=camera_id, is_active=True)
|
|
|
|
if request.method == 'POST':
|
|
form = TimelapseJobCreateForm(request.POST)
|
|
if form.is_valid():
|
|
job = form.save(commit=False)
|
|
job.camera = camera
|
|
job.save()
|
|
return redirect(reverse('camlaps:job_detail', kwargs={'job_id': job.id}))
|
|
else:
|
|
form = TimelapseJobCreateForm()
|
|
|
|
sampling_choices = TimelapseJob.SamplingPreset.choices
|
|
sampling_max = len(TimelapseJob.SAMPLING_PRESET_MINUTES) - 1
|
|
|
|
return render(
|
|
request,
|
|
'camlaps/job_create.html',
|
|
{
|
|
'camera': camera,
|
|
'form': form,
|
|
'sampling_choices': sampling_choices,
|
|
'sampling_max': sampling_max,
|
|
},
|
|
)
|