commit 2b6c18f26e9fc0f7193ac77041f38673d54255af Author: Jack Date: Sun Aug 30 04:34:16 2026 +0000 Inital commit diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000..ffff482 Binary files /dev/null and b/__pycache__/database.cpython-312.pyc differ diff --git a/app.py b/app.py new file mode 100644 index 0000000..37b0c5b --- /dev/null +++ b/app.py @@ -0,0 +1,295 @@ +from flask import Flask, render_template, request, jsonify, send_file, flash, redirect, url_for +from database import init_db, add_camera, get_cameras, delete_camera, add_recording, get_recordings +from apscheduler.schedulers.background import BackgroundScheduler +from datetime import datetime, timedelta +import requests +from requests.auth import HTTPBasicAuth +import os +import logging + +app = Flask(__name__) +app.secret_key = 'your-secret-key-here' # Change this! + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Scheduler for auto-stopping recordings +scheduler = BackgroundScheduler() +scheduler.start() + +# Track active recordings: {recording_id: {'camera_id': id, 'stop_time': datetime}} +active_recordings = {} + +RECORDINGS_DIR = 'recordings' +os.makedirs(RECORDINGS_DIR, exist_ok=True) + + +def make_camera_request(camera, endpoint, params=None, stream=False): + """Make authenticated request to Axis camera API""" + url = f"http://{camera['ip']}:{camera['port']}{endpoint}" + try: + response = requests.get( + url, + auth=HTTPBasicAuth(camera['username'], camera['password']), + params=params, + timeout=30, + stream=stream + ) + response.raise_for_status() + return response + except requests.RequestException as e: + logger.error(f"Camera request failed: {e}") + raise + + +def start_recording_on_camera(camera): + """Start recording on camera and return recording ID""" + response = make_camera_request(camera, '/axis-cgi/record/record.cgi', + {'diskid': 'SD_DISK'}) + # The response typically contains the recording ID + # Parse it from the response text + return response.text + + +def stop_recording_on_camera(camera, recording_id): + """Stop recording on camera""" + response = make_camera_request(camera, '/axis-cgi/record/stop.cgi', + {'recordingid': recording_id}) + return response.text + + +def export_recording(camera, recording_id, output_path): + """Download recording from camera""" + response = make_camera_request( + camera, + '/axis-cgi/record/export/exportrecording.cgi', + {'recordingid': recording_id, 'format': 'mp4'}, + stream=True + ) + + with open(output_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + return output_path + + +def auto_stop_recording(recording_db_id, camera_id, recording_id): + """Scheduled job to stop recording and export""" + from database import get_camera_by_id, update_recording_status + + camera = get_camera_by_id(camera_id) + if not camera: + logger.error(f"Camera {camera_id} not found for stopping recording") + return + + try: + # Stop the recording + stop_recording_on_camera(camera, recording_id) + logger.info(f"Stopped recording {recording_id} on camera {camera['ip']}") + + # Export the video + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"camera_{camera_id}_{timestamp}.mp4" + filepath = os.path.join(RECORDINGS_DIR, filename) + + export_recording(camera, recording_id, filepath) + logger.info(f"Exported recording to {filepath}") + + # Update database + update_recording_status(recording_db_id, 'completed', filepath) + + except Exception as e: + logger.error(f"Failed to stop/export recording: {e}") + update_recording_status(recording_db_id, 'failed', None) + + +@app.route('/') +def index(): + cameras = get_cameras() + recordings = get_recordings() + return render_template('index.html', cameras=cameras, recordings=recordings) + + +@app.route('/cameras/add', methods=['POST']) +def add_camera_route(): + ip = request.form.get('ip') + port = request.form.get('port', 80) + username = request.form.get('username') + password = request.form.get('password') + name = request.form.get('name', f'Camera {ip}') + + if not all([ip, username, password]): + flash('IP, username, and password are required', 'error') + return redirect(url_for('index')) + + try: + add_camera(name, ip, port, username, password) + flash('Camera added successfully', 'success') + except Exception as e: + flash(f'Error adding camera: {e}', 'error') + + return redirect(url_for('index')) + + +@app.route('/cameras/delete/', methods=['POST']) +def delete_camera_route(camera_id): + try: + delete_camera(camera_id) + flash('Camera deleted successfully', 'success') + except Exception as e: + flash(f'Error deleting camera: {e}', 'error') + + return redirect(url_for('index')) + + +@app.route('/cameras/test/') +def test_camera_route(camera_id): + """Test connection to camera""" + from database import get_camera_by_id + camera = get_camera_by_id(camera_id) + + if not camera: + return jsonify({'success': False, 'message': 'Camera not found'}) + + try: + response = make_camera_request(camera, '/axis-cgi/record/list.cgi') + return jsonify({'success': True, 'message': 'Connection successful', 'response': response.text[:500]}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}) + + +@app.route('/recordings/start/', methods=['POST']) +def start_recording_route(camera_id): + from database import get_camera_by_id + + camera = get_camera_by_id(camera_id) + if not camera: + flash('Camera not found', 'error') + return redirect(url_for('index')) + + duration_minutes = int(request.form.get('duration', 5)) + + try: + # Start recording + response = start_recording_on_camera(camera) + logger.info(f"Start recording response: {response}") + + # Try to extract recording ID from response + # Axis typically returns something like "recordingid=12345" + recording_id = None + if 'recordingid=' in response: + recording_id = response.split('recordingid=')[1].split()[0].strip() + + if not recording_id: + # If we can't get ID immediately, we'll need to poll list.cgi + flash('Recording started (ID pending)', 'warning') + return redirect(url_for('index')) + + # Schedule auto-stop + stop_time = datetime.now() + timedelta(minutes=duration_minutes) + + # Add to database + recording_db_id = add_recording( + camera_id=camera_id, + recording_id=recording_id, + duration_minutes=duration_minutes, + scheduled_stop=stop_time + ) + + # Schedule the stop job + scheduler.add_job( + auto_stop_recording, + 'date', + run_date=stop_time, + args=[recording_db_id, camera_id, recording_id], + id=f'stop_{recording_db_id}' + ) + + flash(f'Recording started! Will stop in {duration_minutes} minutes', 'success') + + except Exception as e: + flash(f'Error starting recording: {e}', 'error') + logger.error(f"Start recording error: {e}") + + return redirect(url_for('index')) + + +@app.route('/recordings/stop/', methods=['POST']) +def stop_recording_route(recording_id): + from database import get_recording_by_id, get_camera_by_id + + recording = get_recording_by_id(recording_id) + if not recording or not recording['recording_id']: + flash('Recording not found', 'error') + return redirect(url_for('index')) + + camera = get_camera_by_id(recording['camera_id']) + + try: + stop_recording_on_camera(camera, recording['recording_id']) + + # Cancel scheduled job if exists + try: + scheduler.remove_job(f'stop_{recording_id}') + except: + pass + + # Export immediately + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"camera_{recording['camera_id']}_{timestamp}.mp4" + filepath = os.path.join(RECORDINGS_DIR, filename) + + export_recording(camera, recording['recording_id'], filepath) + + from database import update_recording_status + update_recording_status(recording_id, 'completed', filepath) + + flash('Recording stopped and exported', 'success') + + except Exception as e: + flash(f'Error stopping recording: {e}', 'error') + logger.error(f"Stop recording error: {e}") + + return redirect(url_for('index')) + + +@app.route('/recordings/download/') +def download_recording_route(recording_id): + from database import get_recording_by_id + + recording = get_recording_by_id(recording_id) + if not recording or not recording['file_path']: + flash('Recording file not found', 'error') + return redirect(url_for('index')) + + if not os.path.exists(recording['file_path']): + flash('Recording file not found on disk', 'error') + return redirect(url_for('index')) + + return send_file( + recording['file_path'], + as_attachment=True, + download_name=os.path.basename(recording['file_path']) + ) + + +@app.route('/recordings/delete/', methods=['POST']) +def delete_recording_route(recording_id): + from database import delete_recording + + recording = get_recordings() + rec = next((r for r in recording if r['id'] == recording_id), None) + + if rec and rec.get('file_path') and os.path.exists(rec['file_path']): + os.remove(rec['file_path']) + + delete_recording(recording_id) + flash('Recording deleted', 'success') + return redirect(url_for('index')) + + +if __name__ == '__main__': + init_db() + app.run(debug=True, host='0.0.0.0', port=5800) diff --git a/axis_timelapse.db b/axis_timelapse.db new file mode 100644 index 0000000..deebf40 Binary files /dev/null and b/axis_timelapse.db differ diff --git a/database.py b/database.py new file mode 100644 index 0000000..72447b6 --- /dev/null +++ b/database.py @@ -0,0 +1,142 @@ +import sqlite3 +from datetime import datetime + +DATABASE = 'axis_timelapse.db' + + +def get_connection(): + conn = sqlite3.connect(DATABASE) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + conn = get_connection() + cursor = conn.cursor() + + # Cameras table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS cameras ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + ip TEXT NOT NULL, + port INTEGER DEFAULT 80, + username TEXT NOT NULL, + password TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Recordings table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + camera_id INTEGER NOT NULL, + recording_id TEXT, + duration_minutes INTEGER, + scheduled_stop TIMESTAMP, + actual_stop TIMESTAMP, + status TEXT DEFAULT 'recording', + file_path TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (camera_id) REFERENCES cameras (id) + ) + ''') + + conn.commit() + conn.close() + + +def add_camera(name, ip, port, username, password): + conn = get_connection() + cursor = conn.cursor() + cursor.execute( + 'INSERT INTO cameras (name, ip, port, username, password) VALUES (?, ?, ?, ?, ?)', + (name, ip, port, username, password) + ) + conn.commit() + conn.close() + + +def get_cameras(): + conn = get_connection() + cursor = conn.cursor() + cursor.execute('SELECT * FROM cameras ORDER BY id DESC') + cameras = [dict(row) for row in cursor.fetchall()] + conn.close() + return cameras + + +def get_camera_by_id(camera_id): + conn = get_connection() + cursor = conn.cursor() + cursor.execute('SELECT * FROM cameras WHERE id = ?', (camera_id,)) + camera = cursor.fetchone() + conn.close() + return dict(camera) if camera else None + + +def delete_camera(camera_id): + conn = get_connection() + cursor = conn.cursor() + cursor.execute('DELETE FROM cameras WHERE id = ?', (camera_id,)) + conn.commit() + conn.close() + + +def add_recording(camera_id, recording_id, duration_minutes, scheduled_stop): + conn = get_connection() + cursor = conn.cursor() + cursor.execute( + '''INSERT INTO recordings (camera_id, recording_id, duration_minutes, scheduled_stop, status) + VALUES (?, ?, ?, ?, 'recording')''', + (camera_id, recording_id, duration_minutes, scheduled_stop) + ) + recording_id = cursor.lastrowid + conn.commit() + conn.close() + return recording_id + + +def get_recordings(): + conn = get_connection() + cursor = conn.cursor() + cursor.execute(''' + SELECT r.*, c.name as camera_name, c.ip as camera_ip + FROM recordings r + JOIN cameras c ON r.camera_id = c.id + ORDER BY r.created_at DESC + ''') + recordings = [dict(row) for row in cursor.fetchall()] + conn.close() + return recordings + + +def get_recording_by_id(recording_id): + conn = get_connection() + cursor = conn.cursor() + cursor.execute('SELECT * FROM recordings WHERE id = ?', (recording_id,)) + recording = cursor.fetchone() + conn.close() + return dict(recording) if recording else None + + +def update_recording_status(recording_id, status, file_path=None): + conn = get_connection() + cursor = conn.cursor() + cursor.execute( + '''UPDATE recordings + SET status = ?, actual_stop = CURRENT_TIMESTAMP, file_path = ? + WHERE id = ?''', + (status, file_path, recording_id) + ) + conn.commit() + conn.close() + + +def delete_recording(recording_id): + conn = get_connection() + cursor = conn.cursor() + cursor.execute('DELETE FROM recordings WHERE id = ?', (recording_id,)) + conn.commit() + conn.close() diff --git a/file.mkv b/file.mkv new file mode 100644 index 0000000..6e5619a Binary files /dev/null and b/file.mkv differ diff --git a/file.mp4 b/file.mp4 new file mode 100644 index 0000000..4b7b3ab --- /dev/null +++ b/file.mp4 @@ -0,0 +1,4 @@ +400 Bad Request, The request had bad syntax or was inherently impossible to be satisfied. +

400 Bad Request

+The request had bad syntax or was inherently impossible to be satisfied. + diff --git a/file2.mkv b/file2.mkv new file mode 100644 index 0000000..5383a05 Binary files /dev/null and b/file2.mkv differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..74324bd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask==3.0.0 +apscheduler==3.10.4 +requests==2.31.0 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..1de28ee --- /dev/null +++ b/static/style.css @@ -0,0 +1,146 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background: #1a1a2e; + color: #eee; + padding: 20px; + line-height: 1.6; +} + +.container { + max-width: 1200px; + margin: 0 auto; +} + +h1 { + text-align: center; + margin-bottom: 30px; + color: #00d9ff; +} + +h2 { + margin-bottom: 20px; + color: #00d9ff; + border-bottom: 2px solid #00d9ff; + padding-bottom: 10px; +} + +.card { + background: #16213e; + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); +} + +.form-inline { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; +} + +.form-input { + padding: 10px; + border: 1px solid #0f3460; + border-radius: 4px; + background: #0f3460; + color: #eee; + font-size: 14px; +} + +.form-input::placeholder { + color: #888; +} + +.input-small { + width: 80px; +} + +.btn { + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + text-decoration: none; + display: inline-block; + transition: opacity 0.2s; +} + +.btn:hover { + opacity: 0.8; +} + +.btn-primary { background: #00d9ff; color: #000; } +.btn-secondary { background: #6c757d; color: #fff; } +.btn-success { background: #28a745; color: #fff; } +.btn-warning { background: #ffc107; color: #000; } +.btn-danger { background: #dc3545; color: #fff; } + +.data-table { + width: 100%; + border-collapse: collapse; + margin-top: 10px; +} + +.data-table th, +.data-table td { + padding: 12px; + text-align: left; + border-bottom: 1px solid #0f3460; +} + +.data-table th { + background: #0f3460; + color: #00d9ff; +} + +.data-table tr:hover { + background: #0f3460; +} + +.recording-row { + background: #1a1a2e; +} + +.recording-row td { + padding: 15px 12px; +} + +.actions { + display: flex; + gap: 5px; + flex-wrap: wrap; +} + +.flash { + padding: 15px; + border-radius: 4px; + margin-bottom: 20px; +} + +.flash-success { background: #28a745; } +.flash-error { background: #dc3545; } +.flash-warning { background: #ffc107; color: #000; } + +.status-badge { + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: bold; + text-transform: uppercase; +} + +.status-recording { background: #ffc107; color: #000; } +.status-completed { background: #28a745; color: #fff; } +.status-failed { background: #dc3545; color: #fff; } + +.status-recording { background: rgba(255, 193, 7, 0.2); border: 1px solid #ffc107; } +.status-completed { background: rgba(40, 167, 69, 0.2); border: 1px solid #28a745; } +.status-failed { background: rgba(220, 53, 69, 0.2); border: 1px solid #dc3545; } diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..907e306 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,144 @@ + + + + + + Axis Camera Timelapse Manager + + + +
+

📹 Axis Camera Timelapse Manager

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + + +
+

Add New Camera

+
+ + + + + + +
+
+ + +
+

📷 Cameras

+ + + + + + + + + + + + {% for camera in cameras %} + + + + + + + + + + + {% endfor %} + +
NameIP AddressPortUsernameActions
{{ camera.name }}{{ camera.ip }}{{ camera.port }}{{ camera.username }} + +
+ +
+
+
+ + + minutes + +
+
+
+ + +
+

🎬 Recordings

+ + + + + + + + + + + + + {% for recording in recordings %} + + + + + + + + + {% endfor %} + +
CameraStartedDurationScheduled StopStatusActions
{{ recording.camera_name }} ({{ recording.camera_ip }}){{ recording.created_at }}{{ recording.duration_minutes }} min{{ recording.scheduled_stop or 'N/A' }} + + {{ recording.status }} + + + {% if recording.status == 'recording' %} +
+ +
+ {% endif %} + + {% if recording.status == 'completed' and recording.file_path %} + ⬇ Download + {% endif %} + +
+ +
+
+
+
+ + + + diff --git a/video.mkv b/video.mkv new file mode 100644 index 0000000..ab7651e --- /dev/null +++ b/video.mkv @@ -0,0 +1,12 @@ + + +401 Unauthorized + +

Unauthorized

+

This server could not verify that you +are authorized to access the document +requested. Either you supplied the wrong +credentials (e.g., bad password), or your +browser doesn't understand how to supply +the credentials required.

+