commit b9a1bb3e93d1106d8ab73bf72dfdb24543d1428c Author: Jack Date: Sun Aug 30 05:16:30 2026 +0000 initial commit diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000..391fbcc Binary files /dev/null and b/__pycache__/database.cpython-312.pyc differ diff --git a/__pycache__/scheduler.cpython-312.pyc b/__pycache__/scheduler.cpython-312.pyc new file mode 100644 index 0000000..74385a1 Binary files /dev/null and b/__pycache__/scheduler.cpython-312.pyc differ diff --git a/app.py b/app.py new file mode 100644 index 0000000..efd4531 --- /dev/null +++ b/app.py @@ -0,0 +1,232 @@ +from flask import Flask, render_template, request, jsonify, send_file, send_from_directory +from datetime import datetime +from pathlib import Path +import subprocess +import re +import os + +from database import init_db, add_project, get_all_projects, get_project, update_last_capture, delete_project +from scheduler import scheduler, start_job, stop_job, start_all_jobs + +app = Flask(__name__) +CAPTURES_DIR = Path('captures') +TIMELAPSES_DIR = Path('timelapses') + +# Ensure directories exist +CAPTURES_DIR.mkdir(exist_ok=True) +TIMELAPSES_DIR.mkdir(exist_ok=True) + +@app.route('/') +def index(): + return render_template('index.html') + +@app.route('/api/projects', methods=['GET']) +def list_projects(): + projects = get_all_projects() + result = [] + for p in projects: + project_id, name, ip, port, period, username, password, created, last_capture, active = p + + # Count images and get last image + safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name) + capture_folder = CAPTURES_DIR / safe_name + + image_count = 0 + last_image = None + + if capture_folder.exists(): + images = sorted(capture_folder.glob('*.jpg')) + image_count = len(images) + if images: + last_image = f"/api/projects/{project_id}/image/latest" + + # Calculate runtime + created_dt = datetime.strptime(created, '%Y-%m-%d %H:%M:%S') + runtime = datetime.now() - created_dt + + result.append({ + 'id': project_id, + 'name': name, + 'ip_address': ip, + 'port': port, + 'period_minutes': period, + 'created': created, + 'runtime_hours': round(runtime.total_seconds() / 3600, 2), + 'image_count': image_count, + 'last_capture': last_capture, + 'has_auth': bool(username and password), + 'last_image': last_image + }) + + return jsonify(result) + +@app.route('/api/projects', methods=['POST']) +def create_project(): + data = request.json + name = data.get('name', '').strip() + ip_address = data.get('ip_address', '').strip() + port = data.get('port') + period_minutes = data.get('period_minutes') + username = data.get('username', '').strip() or None + password = data.get('password', '').strip() or None + + # Basic validation + if not name or not ip_address or not port or not period_minutes: + return jsonify({'error': 'All required fields must be filled'}), 400 + + # Validate IP address format + ip_pattern = r'^(\d{1,3}\.){3}\d{1,3}$' + if not re.match(ip_pattern, ip_address): + return jsonify({'error': 'Invalid IP address format'}), 400 + + # Validate port + if not (1 <= port <= 65535): + return jsonify({'error': 'Port must be between 1 and 65535'}), 400 + + # Create project in database + project_id = add_project(name, ip_address, port, period_minutes, username, password) + if not project_id: + return jsonify({'error': 'Project name already exists'}), 400 + + # Start scheduler job + start_job(project_id, ip_address, port, period_minutes, username, password, name) + + return jsonify({'success': True, 'project_id': project_id}) + +@app.route('/api/projects/', methods=['DELETE']) +def remove_project(project_id): + project = get_project(project_id) + if not project: + return jsonify({'error': 'Project not found'}), 404 + + stop_job(project_id) + delete_project(project_id) + + return jsonify({'success': True}) + +@app.route('/api/projects//image/latest') +def get_latest_image(project_id): + """Serve the latest captured image""" + project = get_project(project_id) + if not project: + return jsonify({'error': 'Project not found'}), 404 + + _, name, _, _, _, _, _, _, _, _ = project + safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name) + capture_folder = CAPTURES_DIR / safe_name + + if not capture_folder.exists(): + return jsonify({'error': 'No images found'}), 404 + + images = sorted(capture_folder.glob('*.jpg')) + if not images: + return jsonify({'error': 'No images found'}), 404 + + return send_file(images[-1], mimetype='image/jpeg') + +@app.route('/api/projects//gallery') +def get_gallery(project_id): + """Get list of images for gallery (last 50)""" + project = get_project(project_id) + if not project: + return jsonify({'error': 'Project not found'}), 404 + + _, name, _, _, _, _, _, _, _, _ = project + safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name) + capture_folder = CAPTURES_DIR / safe_name + + if not capture_folder.exists(): + return jsonify({'error': 'No images found'}), 404 + + images = sorted(capture_folder.glob('*.jpg'), reverse=True)[:50] + + result = [] + for img in images: + stat = img.stat() + result.append({ + 'filename': img.name, + 'path': f"/api/projects/{project_id}/image/{img.name}", + 'size': stat.st_size, + 'created': datetime.fromtimestamp(stat.st_ctime).isoformat() + }) + + return jsonify(result) + +@app.route('/api/projects//image/') +def get_image(project_id, filename): + """Serve a specific image""" + project = get_project(project_id) + if not project: + return jsonify({'error': 'Project not found'}), 404 + + _, name, _, _, _, _, _, _, _, _ = project + safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name) + capture_folder = CAPTURES_DIR / safe_name + + return send_from_directory(str(capture_folder), filename, mimetype='image/jpeg') + +@app.route('/api/projects//timelapse', methods=['POST']) +def create_timelapse(project_id): + project = get_project(project_id) + if not project: + return jsonify({'error': 'Project not found'}), 404 + + _, name, _, _, _, _, _, _, _, _ = project + safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name) + capture_folder = CAPTURES_DIR / safe_name + + if not capture_folder.exists(): + return jsonify({'error': 'No captures found'}), 404 + + # Get all images sorted by name + images = sorted(capture_folder.glob('*.jpg')) + if len(images) < 2: + return jsonify({'error': 'Need at least 2 images to create timelapse'}), 400 + + # Create video filename + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + video_filename = f"{safe_name}_{timestamp}.mp4" + video_path = TIMELAPSES_DIR / video_filename + + # Create image list file for ffmpeg + list_file = capture_folder / 'images.txt' + with open(list_file, 'w') as f: + for img in images: + f.write(f"file '{img.absolute()}'\n") + + # Run ffmpeg to create timelapse + ffmpeg_cmd = [ + 'ffmpeg', '-y', + '-f', 'concat', + '-safe', '0', + '-i', str(list_file), + '-vf', 'fps=30', + '-c:v', 'libx264', + '-pix_fmt', 'yuv420p', + str(video_path) + ] + + result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True) + + if result.returncode != 0: + return jsonify({'error': f'ffmpeg failed: {result.stderr}'}), 500 + + # Send file for download + return send_file( + video_path, + mimetype='video/mp4', + as_attachment=True, + download_name=video_filename + ) + +if __name__ == '__main__': + init_db() + + # Start scheduler + scheduler.start() + + # Load existing projects and restart their jobs + projects = get_all_projects() + start_all_jobs(projects) + + app.run(host='0.0.0.0', port=5800, debug=True, use_reloader=False) diff --git a/database.py b/database.py new file mode 100644 index 0000000..24dd70d --- /dev/null +++ b/database.py @@ -0,0 +1,72 @@ +import sqlite3 +from datetime import datetime + +DB_PATH = 'timelapse.db' + +def init_db(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute(''' + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + ip_address TEXT NOT NULL, + port INTEGER NOT NULL, + period_minutes INTEGER NOT NULL, + username TEXT, + password TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_capture TIMESTAMP, + active INTEGER DEFAULT 1 + ) + ''') + conn.commit() + conn.close() + +def add_project(name, ip_address, port, period_minutes, username=None, password=None): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + try: + c.execute(''' + INSERT INTO projects (name, ip_address, port, period_minutes, username, password) + VALUES (?, ?, ?, ?, ?, ?) + ''', (name, ip_address, port, period_minutes, username, password)) + conn.commit() + return c.lastrowid + except sqlite3.IntegrityError: + return None + finally: + conn.close() + +def get_all_projects(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute('SELECT * FROM projects WHERE active = 1') + projects = c.fetchall() + conn.close() + return projects + +def get_project(project_id): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute('SELECT * FROM projects WHERE id = ?', (project_id,)) + project = c.fetchone() + conn.close() + return project + +def update_last_capture(project_id): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute(''' + UPDATE projects SET last_capture = CURRENT_TIMESTAMP + WHERE id = ? + ''', (project_id,)) + conn.commit() + conn.close() + +def delete_project(project_id): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute('UPDATE projects SET active = 0 WHERE id = ?', (project_id,)) + conn.commit() + conn.close() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..38173b6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask==3.0.0 +apscheduler==3.10.4 diff --git a/scheduler.py b/scheduler.py new file mode 100644 index 0000000..f645764 --- /dev/null +++ b/scheduler.py @@ -0,0 +1,72 @@ +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.interval import IntervalTrigger +import requests +from pathlib import Path +from datetime import datetime +import re + +scheduler = BackgroundScheduler() +jobs = {} + +def capture_image(project_id, ip_address, port, username, password, capture_folder): + """Capture an image from the camera""" + try: + # Build URL with optional auth + if username and password: + url = f"http://{username}:{password}@{ip_address}:{port}/axis-cgi/jpg/image.cgi" + else: + url = f"http://{ip_address}:{port}/axis-cgi/jpg/image.cgi" + + # Create timestamped filename + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', capture_folder) + folder_path = Path(f"captures/{safe_name}") + folder_path.mkdir(parents=True, exist_ok=True) + + output_path = folder_path / f"{timestamp}.jpg" + + # Fetch image + response = requests.get(url, timeout=10) + response.raise_for_status() + + # Save image + with open(output_path, 'wb') as f: + f.write(response.content) + + print(f"Captured image for project {project_id}: {output_path}") + return True + + except Exception as e: + print(f"Failed to capture image for project {project_id}: {e}") + return False + +def start_job(project_id, ip_address, port, period_minutes, username, password, capture_folder): + """Start a scheduled capture job""" + # Remove existing job if any + stop_job(project_id) + + # Add new job + job = scheduler.add_job( + capture_image, + trigger=IntervalTrigger(minutes=period_minutes), + id=f"project_{project_id}", + args=[project_id, ip_address, port, username, password, capture_folder], + replace_existing=True + ) + jobs[project_id] = job + print(f"Started job for project {project_id} (every {period_minutes} minutes)") + +def stop_job(project_id): + """Stop a scheduled capture job""" + job_id = f"project_{project_id}" + if scheduler.get_job(job_id): + scheduler.remove_job(job_id) + print(f"Stopped job for project {project_id}") + if project_id in jobs: + del jobs[project_id] + +def start_all_jobs(projects): + """Start jobs for all active projects""" + for p in projects: + project_id, name, ip, port, period, username, password, created, last_capture, active = p + start_job(project_id, ip, port, period, username, password, name) diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..ede50b4 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,342 @@ + + + + + + Timelapse Camera Manager + + + +
+

📷 Timelapse Camera Manager

+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+ + + + + + + + + + + + + + + +
ProjectCameraPeriodRuntimeLast ImageImagesActions
No projects yet. Add one above!
+
+ + + +
+ + + + + + + diff --git a/timelapse.db b/timelapse.db new file mode 100644 index 0000000..f5279d4 Binary files /dev/null and b/timelapse.db differ diff --git a/timelapses/Liberty_Co_20260407_203618.mp4 b/timelapses/Liberty_Co_20260407_203618.mp4 new file mode 100644 index 0000000..5757af9 Binary files /dev/null and b/timelapses/Liberty_Co_20260407_203618.mp4 differ diff --git a/timelapses/Liberty_Co_20260410_191109.mp4 b/timelapses/Liberty_Co_20260410_191109.mp4 new file mode 100644 index 0000000..43c82b9 Binary files /dev/null and b/timelapses/Liberty_Co_20260410_191109.mp4 differ diff --git a/timelapses/Liberty_Co_20260411_033645.mp4 b/timelapses/Liberty_Co_20260411_033645.mp4 new file mode 100644 index 0000000..86c074a Binary files /dev/null and b/timelapses/Liberty_Co_20260411_033645.mp4 differ diff --git a/timelapses/tester11_20260407_161918.mp4 b/timelapses/tester11_20260407_161918.mp4 new file mode 100644 index 0000000..6fff320 Binary files /dev/null and b/timelapses/tester11_20260407_161918.mp4 differ diff --git a/timelapses/tester11_20260407_162557.mp4 b/timelapses/tester11_20260407_162557.mp4 new file mode 100644 index 0000000..ccf0192 Binary files /dev/null and b/timelapses/tester11_20260407_162557.mp4 differ diff --git a/timelapses/tester11_20260407_170834.mp4 b/timelapses/tester11_20260407_170834.mp4 new file mode 100644 index 0000000..80dabce Binary files /dev/null and b/timelapses/tester11_20260407_170834.mp4 differ diff --git a/timelapses/tester11_20260407_174816.mp4 b/timelapses/tester11_20260407_174816.mp4 new file mode 100644 index 0000000..1a27c12 Binary files /dev/null and b/timelapses/tester11_20260407_174816.mp4 differ diff --git a/timelapses/tester11_20260407_193709.mp4 b/timelapses/tester11_20260407_193709.mp4 new file mode 100644 index 0000000..aaecfca Binary files /dev/null and b/timelapses/tester11_20260407_193709.mp4 differ