ripped off from EE-status-v3, changed it to pull list of videos and play recorded mp4s
commit
78d61c122f
|
@ -0,0 +1,14 @@
|
|||
__pycache__
|
||||
*.pyc
|
||||
.DS_Store
|
||||
my_settings.py
|
||||
*.orig
|
||||
.lazy_login
|
||||
.lazy_login*
|
||||
.ipynb_checkpoints/
|
||||
.ipynb_checkpoints/*
|
||||
flask_session/
|
||||
git-version.txt
|
||||
settings.py
|
||||
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
|
||||
FROM python:3.9-slim
|
||||
|
||||
COPY ./requirements.txt /app/requirements.txt
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt update
|
||||
|
||||
RUN pip install --upgrade pip
|
||||
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
|
||||
CMD [ "./startup.sh" ]
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2023 Mark Cotton
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -0,0 +1,37 @@
|
|||
# EE-downloader-v3 #
|
||||
|
||||
## Summary ##
|
||||
This is a python webapp for downloading recorder videos from your cameras.
|
||||
|
||||
## Settings File ##
|
||||
There is file `settings.py` that is needed to run. It should look similar to this:
|
||||
|
||||
```
|
||||
config = {
|
||||
|
||||
# Set up your application and get client id/secrete first
|
||||
# https://developerv3.eagleeyenetworks.com/page/my-application
|
||||
"client_id": "",
|
||||
"client_secret": "",
|
||||
|
||||
# you will need to add approved redirect_uris in your application
|
||||
# this examples assumes you've added http://127.0.0.1:3333/login_callback
|
||||
# change the following variables if you did something different
|
||||
# Note: do not use localhost for server_host, use 127.0.0.1 instead
|
||||
"server_protocol": "http",
|
||||
"server_host": "127.0.0.1",
|
||||
"server_port": "3333",
|
||||
"server_path": "login_callback",
|
||||
|
||||
# preferences
|
||||
"log_level": "INFO"
|
||||
}
|
||||
```
|
||||
|
||||
You can create your application and setup credentials at: [https://developerv3.eagleeyenetworks.com/page/my-application-html](my applications). You can also reach out to api_support@een.com for help.
|
||||
|
||||
|
||||
|
||||
## Ideas on how to use ##
|
||||
|
||||
I encluded an example Flask server in `app.py`. You can run it locally with `python flask run -p 3333 --debug`
|
|
@ -0,0 +1,339 @@
|
|||
|
||||
import json, requests
|
||||
from flask import Flask, request, session, render_template, redirect, url_for, Response, send_file
|
||||
from flask_session import Session
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from matplotlib.figure import Figure
|
||||
import matplotlib.dates as mdates
|
||||
import base64
|
||||
from tqdm import tqdm
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger()
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from EagleEyev3 import *
|
||||
from settings import config
|
||||
|
||||
|
||||
SECRET_KEY = "this needs to be changed to something unique and not checked into git"
|
||||
|
||||
|
||||
# check if it could pull in a config object from settings.py
|
||||
if config:
|
||||
# start checking for keys in config object and set sensible defaults
|
||||
if 'days_of_history' in config:
|
||||
DAYS_OF_HISTORY = config['days_of_history']
|
||||
else:
|
||||
# fallback to a sane default
|
||||
DAYS_OF_HISTORY = 1
|
||||
|
||||
if 'log_level' in config:
|
||||
logger.setLevel(config['log_level'])
|
||||
|
||||
if 'client_secret' in config:
|
||||
SECRET_KEY = config['client_secret']
|
||||
|
||||
else:
|
||||
logging.setLevel(config['INFO'])
|
||||
|
||||
|
||||
|
||||
|
||||
logging.info(f"Using EagleEyev3 version {EagleEyev3.__version__}")
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
app.secret_key = SECRET_KEY
|
||||
SESSION_TYPE = 'filesystem'
|
||||
SESSION_PERMANENT = False
|
||||
PERMANENT_SESSION_LIFETIME = 60 * 60 * 24 * 7 # one week in seconds
|
||||
app.config.from_object(__name__)
|
||||
Session(app)
|
||||
|
||||
|
||||
# $ flask routes -s rule
|
||||
# INFO:root:Using EagleEyev3 version 0.0.15
|
||||
# Endpoint Methods Rule
|
||||
# --------------------- ------- ---------------------------
|
||||
# index GET /
|
||||
# accounts GET /accounts
|
||||
# camera_live_preivew GET /camera/<esn>/preview
|
||||
# camera__preivew_image GET /camera/<esn>/preview_image
|
||||
# cameras GET /cameras
|
||||
# landing GET /landing
|
||||
# login_callback GET /login_callback
|
||||
# logout GET /logout
|
||||
# static GET /static/<path:filename>
|
||||
# switch_account GET /switch_account
|
||||
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
logging.debug(session)
|
||||
if 'een' in session and session['een'].access_token:
|
||||
return f(*args, **kwargs)
|
||||
else:
|
||||
# failed to find a valid session so redirecting to landing page
|
||||
logging.warn('@login_requried failed to find a valid session')
|
||||
logging.info(session)
|
||||
return redirect(url_for('landing'))
|
||||
return decorated_function
|
||||
|
||||
|
||||
|
||||
@app.route('/landing')
|
||||
def landing():
|
||||
|
||||
# sometimes you just want to get to the landing page and want to avoid the index redirect logic
|
||||
if 'een' in session:
|
||||
een = session['een']
|
||||
else:
|
||||
een = EagleEyev3(config)
|
||||
session['een'] = een
|
||||
|
||||
base_url = "https://auth.eagleeyenetworks.com/oauth2/authorize"
|
||||
path_url = f"?client_id={een.client_id}&response_type=code&scope=vms.all&redirect_uri={een.redirect_uri}"
|
||||
values = { "login_link": f"{base_url}{path_url}" }
|
||||
return render_template('cover.html', template_values=values)
|
||||
|
||||
|
||||
@app.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
|
||||
# get een instance from session, @login_required already checked for it
|
||||
een = session['een']
|
||||
|
||||
# using current_user as a proxy for an established valid session
|
||||
if een.access_token == None:
|
||||
base_url = "https://auth.eagleeyenetworks.com/oauth2/authorize"
|
||||
path_url = f"?client_id={een.client_id}&response_type=code&scope=vms.all&redirect_uri={een.redirect_uri}"
|
||||
values = { "login_link": f"{base_url}{path_url}" }
|
||||
return render_template('cover.html', template_values=values)
|
||||
|
||||
# call this to check if session is actually valid
|
||||
check = een.get_current_user()
|
||||
if 'success' not in check or check['success'] == False:
|
||||
base_url = "https://auth.eagleeyenetworks.com/oauth2/authorize"
|
||||
path_url = f"?client_id={een.client_id}&response_type=code&scope=vms.all&redirect_uri={een.redirect_uri}"
|
||||
|
||||
values = { "login_link": f"{base_url}{path_url}" }
|
||||
return render_template('cover.html', template_values=values)
|
||||
else:
|
||||
logging.info(f"{check['success']} - check get_current_user {een.current_user['email']} {een.access_token}")
|
||||
|
||||
|
||||
een.get_list_of_cameras()
|
||||
een.get_list_of_accounts()
|
||||
|
||||
if len(een.accounts) > 0 and een.active_account == None:
|
||||
# they need to pick and account
|
||||
|
||||
logging.info(f"redirecting to accounts.html because they don't have an active account { een.active_account }")
|
||||
|
||||
values = {
|
||||
"current_user": een.current_user,
|
||||
"accounts": een.accounts,
|
||||
"active_account": een.active_account,
|
||||
"user_base_url": een.user_base_url,
|
||||
"access_token": een.access_token
|
||||
}
|
||||
redirect(url_for('accounts'))
|
||||
|
||||
values = {
|
||||
"current_user": een.current_user,
|
||||
"cameras": een.cameras,
|
||||
"camera_count": len(een.cameras),
|
||||
"camera_count_online": len([i for i in een.cameras if i.is_online()]),
|
||||
"camera_count_offline": len([i for i in een.cameras if i.is_offline()]),
|
||||
"accounts": een.accounts,
|
||||
"active_account": een.active_account,
|
||||
"user_base_url": een.user_base_url,
|
||||
"access_token": een.access_token
|
||||
}
|
||||
|
||||
return render_template('index.html', template_values=values)
|
||||
|
||||
|
||||
@app.route('/login_callback')
|
||||
def login_callback():
|
||||
|
||||
# This is getting the ?code= querystring value from the HTTP request.
|
||||
code = request.args.get('code')
|
||||
|
||||
# create a new een object and store it in their session
|
||||
een = EagleEyev3(config)
|
||||
session['een'] = een
|
||||
|
||||
|
||||
if (code):
|
||||
# use the include code parameter to complete login process
|
||||
oauth_object = een.login_tokens(code)
|
||||
|
||||
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
|
||||
# logout isn't working APIv3 right now so don't wait for the call to fail before logging out
|
||||
# if 'een' in session:
|
||||
# een = session['een']
|
||||
# een.logout()
|
||||
|
||||
session.pop('een')
|
||||
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
@app.route('/cameras')
|
||||
@login_required
|
||||
def cameras():
|
||||
|
||||
# get een instance from session, @login_required already checked for it
|
||||
een = session['een']
|
||||
|
||||
logging.debug(een.get_list_of_cameras())
|
||||
|
||||
values = {
|
||||
"current_user": een.current_user,
|
||||
"cameras": een.cameras,
|
||||
"camera_count": len(een.cameras),
|
||||
"camera_count_online": len([i for i in een.cameras if i.is_online()]),
|
||||
"camera_count_offline": len([i for i in een.cameras if i.is_offline()])
|
||||
}
|
||||
|
||||
return render_template('cameras_partial.html', template_values=values)
|
||||
|
||||
|
||||
@app.route('/accounts')
|
||||
@login_required
|
||||
def accounts():
|
||||
|
||||
# get een instance from session, @login_required already checked for it
|
||||
een = session['een']
|
||||
|
||||
logging.debug(een.get_list_of_accounts())
|
||||
|
||||
values = {
|
||||
"current_user": een.current_user,
|
||||
"accounts": een.accounts,
|
||||
"active_account": een.active_account
|
||||
}
|
||||
|
||||
return render_template('accounts_partial.html', template_values=values)
|
||||
|
||||
|
||||
@app.route('/switch_account')
|
||||
@login_required
|
||||
def switch_account():
|
||||
|
||||
# This is getting the ?account= querystring value from the HTTP request.
|
||||
account = request.args.get('account')
|
||||
|
||||
# get een instance from session, @login_required already checked for it
|
||||
een = session['een']
|
||||
|
||||
if (account):
|
||||
# switch into account
|
||||
logging.info(f"attempting to switch into account {account}")
|
||||
logging.debug(een.login_from_reseller(target_account_id=account))
|
||||
|
||||
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
@app.route('/camera/<esn>/preview_image')
|
||||
@login_required
|
||||
def camera__preivew_image(esn=None):
|
||||
|
||||
# get een instance from session, @login_required already checked for it
|
||||
een = session['een']
|
||||
|
||||
camera = een.get_camera_by_id(esn)
|
||||
res = camera.get_live_preview()
|
||||
|
||||
if res:
|
||||
return send_file(BytesIO(res.content), mimetype='image/jpeg')
|
||||
else:
|
||||
return send_file('static/placeholder.png')
|
||||
|
||||
|
||||
@app.route('/camera/<esn>/preview')
|
||||
@login_required
|
||||
def camera_live_preivew(esn=None):
|
||||
|
||||
# get een instance from session, @login_required already checked for it
|
||||
een = session['een']
|
||||
|
||||
camera = een.get_camera_by_id(esn)
|
||||
|
||||
values = {
|
||||
"current_user": een.current_user,
|
||||
"camera": camera,
|
||||
"events": camera.events['status']
|
||||
}
|
||||
|
||||
return render_template('camera_preview.html', template_values=values)
|
||||
|
||||
|
||||
@app.route('/camera/<esn>/video_player/<start_timestamp>')
|
||||
@login_required
|
||||
def camera_video_player(esn=None, start_timestamp=None):
|
||||
|
||||
# get een instance from session, @login_required already checked for it
|
||||
een = session['een']
|
||||
|
||||
camera = een.get_camera_by_id(esn)
|
||||
|
||||
video_url = next(x['mp4Url'] for x in camera.videos if x['startTimestamp'] == start_timestamp)
|
||||
|
||||
values = {
|
||||
"current_user": een.current_user,
|
||||
"camera_name": camera.name,
|
||||
"start_timestamp": start_timestamp,
|
||||
"video_url": video_url
|
||||
}
|
||||
|
||||
return render_template('camera_video_player.html', template_values=values)
|
||||
|
||||
|
||||
@app.route('/camera/<esn>/videos')
|
||||
@login_required
|
||||
def camera_list_of_videos(esn=None):
|
||||
|
||||
# get een instance from session, @login_required already checked for it
|
||||
een = session['een']
|
||||
|
||||
camera = een.get_camera_by_id(esn)
|
||||
logging.debug(camera.get_list_of_videos(start_timestamp=een.time_before(hours=12), end_timestamp=een.time_now()))
|
||||
|
||||
values = {
|
||||
"current_user": een.current_user,
|
||||
"camera": { 'id': camera.id, 'name': camera.name },
|
||||
"videos": camera.videos
|
||||
}
|
||||
|
||||
|
||||
if 'HX-Request' in request.headers:
|
||||
return render_template('camera_videos_partial.html', template_values=values)
|
||||
else:
|
||||
return values
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host=config.server_host, port=config.server_port)
|
|
@ -0,0 +1,13 @@
|
|||
version: '3'
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
volumes:
|
||||
- .:/app
|
||||
ports:
|
||||
- "9400:3000"
|
||||
restart: always
|
||||
tty: true
|
||||
user: 1000:1000
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
pytz==2023.3
|
||||
requests==2.29.0
|
||||
blinker==1.6.2
|
||||
certifi==2023.5.7
|
||||
charset-normalizer==3.1.0
|
||||
click==8.1.3
|
||||
Flask==2.3.2
|
||||
idna==3.4
|
||||
itsdangerous==2.1.2
|
||||
Jinja2==3.1.2
|
||||
MarkupSafe==2.1.3
|
||||
urllib3==1.26.16
|
||||
Werkzeug==2.3.6
|
||||
gunicorn==20.1.0
|
||||
cachelib==0.10.2
|
||||
Flask-Session==0.5.0
|
||||
EagleEyev3>=0.0.16
|
||||
tqdm
|
||||
pandas
|
||||
numpy
|
||||
matplotlib
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/bash
|
||||
|
||||
#python3 -u server.py
|
||||
gunicorn --bind 0.0.0.0:3000 --limit-request-line 0 --worker-class=gthread --threads=4 wsgi:app
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Globals
|
||||
*/
|
||||
|
||||
/* Links */
|
||||
a,
|
||||
a:focus,
|
||||
a:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Custom default button */
|
||||
.btn-secondary,
|
||||
.btn-secondary:hover,
|
||||
.btn-secondary:focus {
|
||||
color: #333;
|
||||
text-shadow: none; /* Prevent inheritance from `body` */
|
||||
background-color: #fff;
|
||||
border: .05rem solid #fff;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Base structure
|
||||
*/
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
body {
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
color: #fff;
|
||||
text-shadow: 0 .05rem .1rem rgba(0, 0, 0, .5);
|
||||
box-shadow: inset 0 0 5rem rgba(0, 0, 0, .5);
|
||||
}
|
||||
|
||||
.cover-container {
|
||||
max-width: 42em;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Header
|
||||
*/
|
||||
.masthead {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.masthead-brand {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.nav-masthead .nav-link {
|
||||
padding: .25rem 0;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, .5);
|
||||
background-color: transparent;
|
||||
border-bottom: .25rem solid transparent;
|
||||
}
|
||||
|
||||
.nav-masthead .nav-link:hover,
|
||||
.nav-masthead .nav-link:focus {
|
||||
border-bottom-color: rgba(255, 255, 255, .25);
|
||||
}
|
||||
|
||||
.nav-masthead .nav-link + .nav-link {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.nav-masthead .active {
|
||||
color: #fff;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
|
||||
@media (min-width: 48em) {
|
||||
.masthead-brand {
|
||||
float: left;
|
||||
}
|
||||
.nav-masthead {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Cover
|
||||
*/
|
||||
.cover {
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
.cover .btn-lg {
|
||||
padding: .75rem 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Footer
|
||||
*/
|
||||
.mastfoot {
|
||||
color: rgba(255, 255, 255, .5);
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
After Width: | Height: | Size: 1.2 MiB |
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,27 @@
|
|||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-4 offset-5">
|
||||
<h2>
|
||||
{% for account in template_values['accounts'] %}
|
||||
{% if account.id == template_values['active_account'] %}
|
||||
{{ account.name }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<label >Choose Account:</label>
|
||||
<select onchange="this.options[this.selectedIndex].value && (window.location = this.options[this.selectedIndex].value);">
|
||||
{% for account in template_values['accounts'] %}
|
||||
<option value="/switch_account?account={{ account.id }}"
|
||||
{% if account.id == template_values['active_account'] %}
|
||||
selected
|
||||
{% endif %}
|
||||
title="{{ account.id }}">
|
||||
{{ account.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,121 @@
|
|||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="description" content="">
|
||||
<meta name="author" content="">
|
||||
|
||||
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
{% block title %}
|
||||
<title>downloader.mcotton.space</title>
|
||||
{% endblock %}
|
||||
|
||||
<!-- Bootstrap core CSS -->
|
||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
|
||||
|
||||
<link rel="stylesheet" href="//cdn.datatables.net/1.10.19/css/jquery.dataTables.min.css">
|
||||
<link href="/static/dataTables.bootstrap4.min.css">
|
||||
|
||||
<!-- Custom styles for this template -->
|
||||
<!-- <link href="album.css" rel="stylesheet">
|
||||
<link href="custom.css" rel="stylesheet"> -->
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
|
||||
<!-- self-hosted fontawesome -->
|
||||
<link href="/static/css/all.css" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
|
||||
|
||||
<link href="/static/theme.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
.row {
|
||||
border: dashed lightgray 1px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.left_text {
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block style %}
|
||||
{% endblock %}
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<header>
|
||||
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||
|
||||
|
||||
<div class="collapse navbar-collapse">
|
||||
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
|
||||
<li class="nav-item active">
|
||||
<a href="/" class="navbar-brand d-flex align-items-center">
|
||||
<small>downloader</small>.mcotton.space
|
||||
</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
<span class="navbar-text">
|
||||
{% block current_user %}
|
||||
{% endblock %}
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
</header>
|
||||
|
||||
|
||||
<main role="main">
|
||||
|
||||
{% block main %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="text-muted">
|
||||
<div class="container">
|
||||
<p class="float-right">
|
||||
</p>
|
||||
<p></p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Bootstrap core JavaScript
|
||||
================================================== -->
|
||||
<!-- Placed at the end of the document so the pages load faster -->
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js"></script>
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js"></script>
|
||||
|
||||
<script src="//cdn.datatables.net/1.10.19/js/jquery.dataTables.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/1.10.19/js/dataTables.bootstrap4.min.js"></script>
|
||||
|
||||
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
|
||||
|
||||
<script>
|
||||
|
||||
$(document).ready(function() {
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
{% block script %}
|
||||
|
||||
{% endblock%}
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,3 @@
|
|||
<h3>Preview Image <i class="bi bi-card-image"></i></h3>
|
||||
<h5>{{ template_values['camera'].name }}</h5>
|
||||
<img src="/camera/{{ template_values['camera'].id }}/live_preview" style="max-height:360px;">
|
|
@ -0,0 +1,46 @@
|
|||
<h3>List of Events <i class="bi bi-calendar-event"></i></h3>
|
||||
<h5>{{ template_values['camera'].name }}</h5>
|
||||
<a href="/camera/{{ template_values['camera'].id }}/events/3" hx-get="/camera/{{ template_values['camera'].id }}/events/3" hx-trigger="click" hx-target="#camera_status_events" hx-indicator=".progress">
|
||||
<button class="btn btn-outline-success">
|
||||
3x<i class="bi bi-calendar-event" title="click to load events list for 3 days"></i>
|
||||
</button>
|
||||
</a>
|
||||
<a href="/camera/{{ template_values['camera'].id }}/events/7" hx-get="/camera/{{ template_values['camera'].id }}/events/7" hx-trigger="click" hx-target="#camera_status_events" hx-indicator=".progress">
|
||||
<button class="btn btn-outline-success">
|
||||
7x<i class="bi bi-calendar-event" title="click to load events list for 7 days"></i>
|
||||
</button>
|
||||
</a>
|
||||
<a href="/camera/{{ template_values['camera'].id }}/events/14" hx-get="/camera/{{ template_values['camera'].id }}/events/14" hx-trigger="click" hx-target="#camera_status_events" hx-indicator=".progress">
|
||||
<button class="btn btn-outline-success">
|
||||
14x<i class="bi bi-calendar-event" title="click to load events list for 14 days"></i>
|
||||
</button>
|
||||
</a>
|
||||
<a href="/camera/{{ template_values['camera'].id }}/status_plot" hx-get="/camera/{{ template_values['camera'].id }}/status_plot" hx-trigger="click" hx-target="#camera_status_plot" hx-indicator=".progress">
|
||||
<button class="btn btn-outline-success">
|
||||
Graph Events <i class="bi bi-bar-chart" title="click to generate graph of events"></i>
|
||||
</button>
|
||||
</a>
|
||||
{% if template_values['events'] %}
|
||||
{% for event in template_values['events'] %}
|
||||
<div class="row">
|
||||
<div class="col-2 left_text">
|
||||
<small>Status:</small>
|
||||
</div>
|
||||
<div class="col-9 offset-1 left_text">
|
||||
<b>{{ event['data'][0]['newStatus']['connectionStatus'] }}</b>
|
||||
</div>
|
||||
<div class="col-2 left_text">
|
||||
<small>End:</small>
|
||||
</div>
|
||||
<div class="col-9 offset-1 left_text">
|
||||
{{ event['endTimestamp'] }}
|
||||
</div>
|
||||
<div class="col-2 left_text">
|
||||
<small>Start:</small>
|
||||
</div>
|
||||
<div class="col-9 offset-1 left_text">
|
||||
{{ event['startTimestamp'] }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
|
@ -0,0 +1,3 @@
|
|||
<h3>Preview Image <i class="bi bi-card-image"></i></h3>
|
||||
<h5>{{ template_values['camera'].name }}</h5>
|
||||
<img src="/camera/{{ template_values['camera'].id }}/preview_image" style="max-height:360px; max-width:100%">
|
|
@ -0,0 +1,4 @@
|
|||
<h3>Video Player <i class="bi bi-card-image"></i></h3>
|
||||
<h5>{{ template_values['camera_name'] }}</h5>
|
||||
<video src="{{ template_values['video_url'] }}" autoplay muted preload="auto" controls type="video/mp4" style="max-height:360px; max-width:100%"></video>
|
||||
<br><small>{{ template_values['start_timestamp'] }}</small>
|
|
@ -0,0 +1,29 @@
|
|||
<h3>List of Videos <i class="bi bi-calendar-event"></i></h3>
|
||||
<h5>{{ template_values['camera'].name }}</h5>
|
||||
|
||||
{% if template_values['videos'] %}
|
||||
{% for video in template_values['videos'] %}
|
||||
<div class="row">
|
||||
<div class="col-4 left_text">
|
||||
{{ video['endTimestamp'] }}
|
||||
</div>
|
||||
<div class="col-4 left_text">
|
||||
{{ video['startTimestamp'] }}
|
||||
</div>
|
||||
<div class="col-4 left_text">
|
||||
<a href="/camera/{{ video['deviceId'] }}/video_player/{{ video['startTimestamp'] }}" hx-get="/camera/{{ video['deviceId'] }}/video_player/{{ video['startTimestamp'] }}" hx-trigger="click" hx-target="#camera_detail" hx-indicator=".progress">
|
||||
<button class="btn btn-outline-success">
|
||||
<i class="bi bi-play" title="click to play video"></i>
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
<script>
|
||||
|
||||
document.getElementById('')
|
||||
|
||||
</script>
|
|
@ -0,0 +1,51 @@
|
|||
|
||||
<h3>Cameras <i class="bi bi-camera-video"></i></h3>
|
||||
<h5>Online <small>[{{ template_values['camera_count_online'] }} of {{ template_values['camera_count'] }}]</small></h5>
|
||||
{% for camera in template_values['cameras'] %}
|
||||
{% if camera.is_online() %}
|
||||
<div class="row">
|
||||
<div class="col-sm-5 col-md-6 offset-1 left_text" style="overflow: hidden;">
|
||||
<span title="ESN: {{ camera.id }}">{{ camera.name }}</span>
|
||||
</div>
|
||||
<div class="col-sm-6 col-md-5">
|
||||
<a href="/camera/{{ camera.id }}/preview" hx-get="/camera/{{ camera.id }}/preview" hx-trigger="click" hx-target="#camera_detail" hx-indicator=".progress">
|
||||
<button class="btn btn-outline-success">
|
||||
<i class="bi bi-card-image" title="click to load preview image"></i>
|
||||
</button>
|
||||
</a>
|
||||
<a href="/camera/{{ camera.id }}/events" hx-get="/camera/{{ camera.id }}/videos" hx-trigger="click" hx-target="#camera_status_events" hx-indicator=".progress">
|
||||
<button class="btn btn-outline-success">
|
||||
<i class="bi bi-calendar-event" title="click to load video list"></i>
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<h5>Offline <small>[{{ template_values['camera_count_offline'] }} of {{ template_values['camera_count'] }}]</small></h5>
|
||||
{% for camera in template_values['cameras'] %}
|
||||
{% if camera.is_offline() %}
|
||||
<div class="row">
|
||||
<div class="col-sm-5 col-md-6 offset-1 left_text" style="overflow: hidden;">
|
||||
<span title="ESN: {{ camera.id }} Status: {{ camera.status['connectionStatus'] }}">{{ camera.name }}</span>
|
||||
</div>
|
||||
<div class="col-sm-6 col-md-5">
|
||||
<a href="/camera/{{ camera.id }}/preview" hx-get="/camera/{{ camera.id }}/preview" hx-trigger="click" hx-target="#camera_detail" hx-indicator=".progress">
|
||||
<button class="btn btn-outline-success">
|
||||
<i class="bi bi-card-image" title="click to load preview image"></i>
|
||||
</button>
|
||||
</a>
|
||||
<a href="/camera/{{ camera.id }}/events" hx-get="/camera/{{ camera.id }}/videos" hx-trigger="click" hx-target="#camera_status_events" hx-indicator=".progress">
|
||||
<button class="btn btn-outline-success">
|
||||
<i class="bi bi-calendar-event" title="click to load video list"></i>
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
|
||||
<button hx-get="/cameras" hx-trigger="click" hx-target="#camera_list" class="btn btn-md btn-outline-success" hx-indicator=".progress">refresh <i class="bi bi-arrow-clockwise"></i></button>
|
||||
<br>
|
|
@ -0,0 +1,61 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="description" content="">
|
||||
<meta name="author" content="">
|
||||
<link rel="icon" href="/static/favicon.ico">
|
||||
|
||||
<title>downloader.mcotton.space</title>
|
||||
|
||||
<!-- Bootstrap core CSS -->
|
||||
<link href="/static/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<!-- Custom styles for this template -->
|
||||
<link href="/static/cover.css" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body class="text-center">
|
||||
|
||||
<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
|
||||
<header class="masthead mb-auto">
|
||||
<div class="inner">
|
||||
<h3 class="masthead-brand"><a href="/"><small>downloader</small>.mcotton.space</a></h3>
|
||||
<!-- <nav class="nav nav-masthead justify-content-center">
|
||||
<a class="nav-link active" href="#">Home</a>
|
||||
<a class="nav-link" href="#">Features</a>
|
||||
<a class="nav-link" href="#">Contact</a>
|
||||
</nav>
|
||||
</div> -->
|
||||
</header>
|
||||
|
||||
<main role="main" class="inner cover">
|
||||
<h1 class="cover-heading">Camera Video Downloader</h1>
|
||||
<p class="lead">Want to download all your videos? Think that cold storage is really cool? Do you enjoy sorting filenames by ISO date format? Today is your lucky day.</p>
|
||||
<p class="lead">
|
||||
<a href="{{ template_values['login_link'] }}" class="btn btn-lg btn-secondary">Get Started</a>
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- <video src="/static/cover_movie_2.mp4" style="max-width:100%;" autoplay controls loop muted preload > -->
|
||||
</main>
|
||||
|
||||
<footer class="mastfoot mt-auto">
|
||||
<div class="inner">
|
||||
<p>This project is open-source and can be found at <a href="https://git.mcotton.space/mcotton/EE-downloader-v3">EE-downloader-v3</a> and uses <a href="https://pypi.org/project/EagleEyev3/">EagleEyev3</a>.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Bootstrap core JavaScript
|
||||
================================================== -->
|
||||
<!-- Placed at the end of the document so the pages load faster -->
|
||||
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
|
||||
<script>window.jQuery || document.write('<script src="../../assets/js/vendor/jquery-slim.min.js"><\/script>')</script>
|
||||
<script src="../../assets/js/vendor/popper.min.js"></script>
|
||||
<script src="/static/bootstrap.min.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,177 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block style %}
|
||||
<style>
|
||||
#camera_list_items {
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#events_list {
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
.progress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
border-radius: 2px;
|
||||
background-clip: padding-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress .indeterminate:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
will-change: left, right;
|
||||
-webkit-animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395)
|
||||
infinite;
|
||||
animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite;
|
||||
}
|
||||
.progress .indeterminate:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
will-change: left, right;
|
||||
-webkit-animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
|
||||
infinite;
|
||||
animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
|
||||
infinite;
|
||||
-webkit-animation-delay: 1.15s;
|
||||
animation-delay: 1.15s;
|
||||
}
|
||||
.progress {
|
||||
display: none;
|
||||
}
|
||||
.htmx-request .progress {
|
||||
display: inline;
|
||||
}
|
||||
.htmx-request.progress {
|
||||
display: inline;
|
||||
}
|
||||
@-webkit-keyframes indeterminate {
|
||||
0% {
|
||||
left: -35%;
|
||||
right: 100%;
|
||||
}
|
||||
60% {
|
||||
left: 100%;
|
||||
right: -90%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
right: -90%;
|
||||
}
|
||||
}
|
||||
@keyframes indeterminate {
|
||||
0% {
|
||||
left: -35%;
|
||||
right: 100%;
|
||||
}
|
||||
60% {
|
||||
left: 100%;
|
||||
right: -90%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
right: -90%;
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes indeterminate-short {
|
||||
0% {
|
||||
left: -200%;
|
||||
right: 100%;
|
||||
}
|
||||
60% {
|
||||
left: 107%;
|
||||
right: -8%;
|
||||
}
|
||||
100% {
|
||||
left: 107%;
|
||||
right: -8%;
|
||||
}
|
||||
}
|
||||
@keyframes indeterminate-short {
|
||||
0% {
|
||||
left: -200%;
|
||||
right: 100%;
|
||||
}
|
||||
60% {
|
||||
left: 107%;
|
||||
right: -8%;
|
||||
}
|
||||
100% {
|
||||
left: 107%;
|
||||
right: -8%;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block current_user %}
|
||||
{{ template_values['current_user']['email'] }}
|
||||
<a href="/logout" tabindex="-1">Logout</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
{% if template_values['accounts']|count > 1 %}
|
||||
{% include 'accounts_partial.html' %}
|
||||
{% endif %}
|
||||
|
||||
<div class="progress" style="height: 3px; background-color: white;">
|
||||
<div class="indeterminate" style="background-color: red;"></div>
|
||||
</div>
|
||||
|
||||
<div class="container text-center">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-5" id="camera_list" style="max-height:400px; overflow:auto;">
|
||||
{% include 'cameras_partial.html' %}
|
||||
</div>
|
||||
<div class="col-md-7" id="camera_detail">
|
||||
<h2>Preview Image <i class="bi bi-card-image"></i></h2>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
<div class="row">
|
||||
<div class="col-md-12" id="camera_status_events" style="max-height:800px; overflow:auto;">
|
||||
<h2>List of Videos <i class="bi bi-calendar-event"></i></h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block script %}
|
||||
|
||||
<script>
|
||||
var requestOptions = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
"Authorization" : "Bearer {{ template_values['access_token'] }}"
|
||||
},
|
||||
credentials: 'include'
|
||||
};
|
||||
|
||||
fetch("https://{{ template_values['user_base_url'] }}/api/v3.0/media/session", requestOptions)
|
||||
.then(response => response.json() )
|
||||
.then( body => fetch(body.url, requestOptions) )
|
||||
.then( response => console.log("response status", response.status ) )
|
||||
.catch(error => console.log('error', error));
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
|
@ -0,0 +1,115 @@
|
|||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from EagleEyev3 import EagleEyev3
|
||||
|
||||
class TestEagleEyev3(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Create an instance of EagleEyev3 for testing
|
||||
self.eagle_eye = EagleEyev3()
|
||||
|
||||
@patch('EagleEyev3.requests')
|
||||
def test_login_tokens_success(self, mock_requests):
|
||||
# Mock the requests.post method to return a successful response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {'access_token': 'token', 'refresh_token': 'refresh_token'}
|
||||
mock_requests.post.return_value = mock_response
|
||||
|
||||
result = self.eagle_eye.login_tokens(code='authorization_code', cascade=True)
|
||||
|
||||
self.assertTrue(result['success'])
|
||||
self.assertEqual(result['response_http_status'], 200)
|
||||
self.assertEqual(result['data']['access_token'], 'token')
|
||||
self.assertEqual(result['data']['refresh_token'], 'refresh_token')
|
||||
|
||||
@patch('EagleEyev3.requests')
|
||||
def test_login_tokens_failure(self, mock_requests):
|
||||
# Mock the requests.post method to return an unsuccessful response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 400
|
||||
mock_response.json.return_value = {}
|
||||
mock_requests.post.return_value = mock_response
|
||||
|
||||
result = self.eagle_eye.login_tokens(code='authorization_code', cascade=True)
|
||||
|
||||
self.assertFalse(result['success'])
|
||||
self.assertEqual(result['response_http_status'], 400)
|
||||
self.assertEqual(result['data'], {})
|
||||
|
||||
@patch('EagleEyev3.requests')
|
||||
def test_get_base_url_success(self, mock_requests):
|
||||
# Set access_token for testing
|
||||
self.eagle_eye.access_token = 'access_token'
|
||||
|
||||
# Mock the requests.get method to return a successful response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {'httpsBaseUrl': {'hostname': 'base_url'}}
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
result = self.eagle_eye.get_base_url(cascade=True)
|
||||
|
||||
self.assertTrue(result['success'])
|
||||
self.assertEqual(result['response_http_status'], 200)
|
||||
self.assertEqual(self.eagle_eye.user_base_url, 'base_url')
|
||||
self.assertIsNotNone(result['data'])
|
||||
|
||||
@patch('EagleEyev3.requests')
|
||||
def test_get_base_url_failure(self, mock_requests):
|
||||
# Set access_token for testing
|
||||
self.eagle_eye.access_token = 'access_token'
|
||||
|
||||
# Mock the requests.get method to return an unsuccessful response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 400
|
||||
mock_response.json.return_value = {}
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
result = self.eagle_eye.get_base_url(cascade=True)
|
||||
|
||||
self.assertFalse(result['success'])
|
||||
self.assertEqual(result['response_http_status'], 400)
|
||||
self.assertEqual(self.eagle_eye.user_base_url, None)
|
||||
self.assertEqual(result['data'], {})
|
||||
|
||||
@patch('EagleEyev3.requests')
|
||||
def test_get_current_user_success(self, mock_requests):
|
||||
# Set access_token and user_base_url for testing
|
||||
self.eagle_eye.access_token = 'access_token'
|
||||
self.eagle_eye.user_base_url = 'base_url'
|
||||
|
||||
# Mock the requests.get method to return a successful response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {'user_id': 'user123'}
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
result = self.eagle_eye.get_current_user()
|
||||
|
||||
self.assertTrue(result['success'])
|
||||
self.assertEqual(result['response_http_status'], 200)
|
||||
self.assertEqual(result['data']['user_id'], 'user123')
|
||||
|
||||
@patch('EagleEyev3.requests')
|
||||
def test_get_current_user_failure(self, mock_requests):
|
||||
# Set access_token and user_base_url for testing
|
||||
self.eagle_eye.access_token = 'access_token'
|
||||
self.eagle_eye.user_base_url = 'base_url'
|
||||
|
||||
# Mock the requests.get method to return an unsuccessful response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 400
|
||||
mock_response.json.return_value = {}
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
result = self.eagle_eye.get_current_user()
|
||||
|
||||
self.assertFalse(result['success'])
|
||||
self.assertEqual(result['response_http_status'], 400)
|
||||
self.assertEqual(result['data'], {})
|
||||
|
||||
# Write similar tests for the remaining methods
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
Loading…
Reference in New Issue