Inital commit
This commit is contained in:
Binary file not shown.
@@ -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)
|
||||
Binary file not shown.
+142
@@ -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()
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
flask==3.0.0
|
||||
apscheduler==3.10.4
|
||||
requests==2.31.0
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user