Inital commit

This commit is contained in:
Jack
2026-08-30 04:34:16 +00:00
commit 2b6c18f26e
11 changed files with 746 additions and 0 deletions
Binary file not shown.
+295
View File
@@ -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/<int:camera_id>', 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/<int:camera_id>')
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/<int:camera_id>', 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/<int:recording_id>', 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/<int:recording_id>')
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/<int:recording_id>', 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)
BIN
View File
Binary file not shown.
+142
View File
@@ -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()
BIN
View File
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
<HTML><HEAD><TITLE>400 Bad Request, The request had bad syntax or was inherently impossible to be satisfied.</TITLE></HEAD>
<BODY><H1>400 Bad Request</H1>
The request had bad syntax or was inherently impossible to be satisfied.
</BODY></HTML>
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
flask==3.0.0
apscheduler==3.10.4
requests==2.31.0
+146
View File
@@ -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; }
+144
View File
@@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Axis Camera Timelapse Manager</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="container">
<h1>📹 Axis Camera Timelapse Manager</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash flash-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Add Camera Form -->
<div class="card">
<h2>Add New Camera</h2>
<form action="{{ url_for('add_camera_route') }}" method="POST" class="form-inline">
<input type="text" name="name" placeholder="Camera Name (optional)" class="form-input">
<input type="text" name="ip" placeholder="IP Address" required class="form-input">
<input type="number" name="port" placeholder="Port" value="80" class="form-input input-small">
<input type="text" name="username" placeholder="Username" required class="form-input">
<input type="password" name="password" placeholder="Password" required class="form-input">
<button type="submit" class="btn btn-primary">Add Camera</button>
</form>
</div>
<!-- Cameras Table -->
<div class="card">
<h2>📷 Cameras</h2>
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>IP Address</th>
<th>Port</th>
<th>Username</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for camera in cameras %}
<tr>
<td>{{ camera.name }}</td>
<td>{{ camera.ip }}</td>
<td>{{ camera.port }}</td>
<td>{{ camera.username }}</td>
<td class="actions">
<button onclick="testCamera({{ camera.id }})" class="btn btn-secondary">Test</button>
<form action="{{ url_for('delete_camera_route', camera_id=camera.id) }}" method="POST" style="display:inline;">
<button type="submit" class="btn btn-danger" onclick="return confirm('Delete this camera?')">Delete</button>
</form>
</td>
</tr>
<tr class="recording-row">
<td colspan="5">
<form action="{{ url_for('start_recording_route', camera_id=camera.id) }}" method="POST" class="form-inline">
<label>Record for:</label>
<input type="number" name="duration" value="5" min="1" max="120" class="form-input input-small">
<span>minutes</span>
<button type="submit" class="btn btn-success">▶ Start Recording</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Recordings Table -->
<div class="card">
<h2>🎬 Recordings</h2>
<table class="data-table">
<thead>
<tr>
<th>Camera</th>
<th>Started</th>
<th>Duration</th>
<th>Scheduled Stop</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for recording in recordings %}
<tr class="status-{{ recording.status }}">
<td>{{ recording.camera_name }} ({{ recording.camera_ip }})</td>
<td>{{ recording.created_at }}</td>
<td>{{ recording.duration_minutes }} min</td>
<td>{{ recording.scheduled_stop or 'N/A' }}</td>
<td>
<span class="status-badge status-{{ recording.status }}">
{{ recording.status }}
</span>
</td>
<td class="actions">
{% if recording.status == 'recording' %}
<form action="{{ url_for('stop_recording_route', recording_id=recording.id) }}" method="POST" style="display:inline;">
<button type="submit" class="btn btn-warning">⏹ Stop Now</button>
</form>
{% endif %}
{% if recording.status == 'completed' and recording.file_path %}
<a href="{{ url_for('download_recording_route', recording_id=recording.id) }}" class="btn btn-primary">⬇ Download</a>
{% endif %}
<form action="{{ url_for('delete_recording_route', recording_id=recording.id) }}" method="POST" style="display:inline;">
<button type="submit" class="btn btn-danger" onclick="return confirm('Delete this recording?')">🗑 Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
function testCamera(cameraId) {
fetch(`/cameras/test/${cameraId}`)
.then(response => response.json())
.then(data => {
if (data.success) {
alert('✅ Connection successful!\n\n' + data.response);
} else {
alert('❌ Connection failed: ' + data.message);
}
})
.catch(err => {
alert('❌ Error: ' + err);
});
}
// Auto-refresh every 30 seconds
setTimeout(() => location.reload(), 30000);
</script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>401 Unauthorized</title>
</head><body>
<h1>Unauthorized</h1>
<p>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.</p>
</body></html>