ripped off from EE-status-v3, changed it to pull list of videos and play recorded mp4s

main
Mark Cotton 2023-08-29 11:17:18 -05:00
commit 78d61c122f
26 changed files with 6753 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@ -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

15
Dockerfile Normal file
View File

@ -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" ]

21
LICENSE Normal file
View File

@ -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.

37
README.md Normal file
View File

@ -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`

339
app.py Normal file
View File

@ -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)

13
docker-compose.yaml Normal file
View File

@ -0,0 +1,13 @@
version: '3'
services:
app:
build: .
volumes:
- .:/app
ports:
- "9400:3000"
restart: always
tty: true
user: 1000:1000

21
requirements.txt Normal file
View File

@ -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

4
startup.sh Executable file
View File

@ -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

7
static/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

7
static/bootstrap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

106
static/cover.css Normal file
View File

@ -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);
}

BIN
static/placeholder.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
static/placeholder1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

5528
static/theme.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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>

121
templates/base.html Normal file
View File

@ -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>

View File

@ -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;">

View File

@ -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 %}

View File

@ -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%">

View File

@ -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>

View File

@ -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>

View File

@ -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>

61
templates/cover.html Normal file
View File

@ -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>

177
templates/index.html Normal file
View File

@ -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 %}

115
tests.py Normal file
View File

@ -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()

4
wsgi.py Normal file
View File

@ -0,0 +1,4 @@
from app import app
if __name__ == "__main__":
app.run()