Initial commit

This commit is contained in:
Daniel S. 2021-08-29 15:03:28 +02:00
commit 7523a19d1f
40 changed files with 3984 additions and 0 deletions

40
templates/base.html Normal file
View file

@ -0,0 +1,40 @@
{% from 'bootstrap/utils.html' import render_messages %}
<!doctype html>
<html lang="en">
<head>
{% block head %}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
{% block styles %}
{{ bootstrap.load_css() }}
<link rel="stylesheet" href="{{url_for('static', filename='theme.css')}}">
{% endblock %}
<title>MediaDash</title>
{% endblock %}
</head>
<body>
{% block navbar %}
<nav class="navbar sticky-top navbar-expand-lg navbar-dark" style="background-color: #222;">
<a class="navbar-brand" href="/">MediaDash</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar_main" aria-controls="navbar_main" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbar_main">
{{nav.left_nav.render(renderer='bootstrap4')}}
</div>
</nav>
</div>
{% endblock %}
{% block content %}
<div class={{"container-fluid" if fluid else "container"}}>
{{render_messages()}}
{% block app_content %}{% endblock %}
</div>
{% endblock %}
{% block scripts %}
{{ bootstrap.load_js(with_popper=False) }}
{% endblock %}
</body>
</html>

70
templates/config.html Normal file
View file

@ -0,0 +1,70 @@
{% extends "base.html" %}
{% from 'utils.html' import custom_render_form_row,make_tabs %}
{% from 'bootstrap/form.html' import render_form, render_field, render_form_row %}
{% set col_size = ('lg',2,6) %}
{% set col_size_seq = ('lg',10,1) %}
{% macro render_fields(fields) %}
{% for field in fields %}
{% if field is sequence %}
{{ custom_render_form_row(field|list,col_map={'transcode_edit':('lg',1),'transcode_new':('lg',1)},render_args={'form_type':'horizontal'}) }}
{% else %}
{{ custom_render_form_row([field],render_args={'form_type':'horizontal','horizontal_columns':col_size}) }}
{% endif %}
{% endfor %}
{% endmacro %}
{% set config_tabs = [] %}
{% for name, fields in [
('Jellyfin',[form.jellyfin_url,form.jellyfin_username,form.jellyfin_passwd]),
('QBittorrent',[form.qbt_url,form.qbt_username,form.qbt_passwd]),
('Sonarr',[form.sonarr_url,form.sonarr_api_key]),
('Radarr',[form.radarr_url,form.radarr_api_key]),
('Portainer',[form.portainer_url,form.portainer_username,form.portainer_passwd]),
('Jackett',[form.jackett_url,form.jackett_api_key]),
('Transcode',[form.transcode_default_profile,form.transcode_profiles]),
] %}
{% do config_tabs.append((name,render_fields(fields))) %}
{% endfor %}
{% block app_content %}
<h1>{{title}}</h1>
{% if test %}
{% if test.success %}
<div class="alert alert-success" role="danger">
<h4>Sucess</h4>
</div>
{% else %}
<div class="alert alert-danger" role="danger">
{% for module,error in test.errors.items() %}
{% if error %}
<h4>{{module}}</h4>
{% if error is mapping %}
{% for key,value in error.items() %}
<p><b>{{key}}</b>: {{value}}</p>
{% endfor %}
{% else %}
<b>{{error}}</b>
{% endif %}
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endif %}
{% for field in form %}
{% for error in field.errors %}
<div class="alert alert-danger" role="danger">{{error}}</div>
{% endfor %}
{% endfor %}
<div class="row">
<div class="col">
<form method="post" class="form" enctype="multipart/form-data">
{{ form.csrf_token() }}
{{ make_tabs(config_tabs) }}
{{ custom_render_form_row([form.test, form.save],button_map={'test':'primary','save':'success'},col_map={'test':0,'primary':0},render_args={'form_type':'horizontal'})}}
</form>
{# render_form(form, form_type ="horizontal", button_map={'test':'primary','save':'success'}) #}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block app_content %}
<h1>
<a href="{{config.APP_CONFIG.portainer_url}}#/containers/{{container.Id}}">
{{container.Config.Labels["com.docker.compose.project"]}}/{{container.Config.Labels["com.docker.compose.service"]}}
</a>
</h1>
<h4>Env</h4>
<pre>{{container.Config.Env|join("\n")}}</pre>
<pre>{{container|tojson(indent=4)}}</pre>
{% endblock %}

View file

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% from "utils.html" import make_tabs %}
{% macro container_row(info) %}
<div class="row">
<div class="col">
Image
</div>
<div class="col">
<a href="{{urljoin('https://hub.docker.com/r/',info.Image)}}">{{info.Image}}</a>
</div>
<div class="col">
Status
</div>
<div class="col">
{{info.Status}}
</div>
</div>
<div class="row">
<div class="col">
Id: <a href="{{'{}#/containers/{}'.format(config.APP_CONFIG.portainer_url,info.Id)}}">{{info.Id}}</a>
</div>
</div>
<div class="row">
<pre>{{info|tojson(indent=4)}}</pre>
</div>
{% endmacro %}
{% block app_content %}
<h1>
<a href="{{config.APP_CONFIG.portainer_url}}">Portainer</a>
</h1>
<table class="table table-sm">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Image</th>
<th scope="col">Status</th>
</tr>
</thead>
{% for container in containers %}
{% set label = container.Labels["com.docker.compose.service"] %}
<tr>
<td>
<a href="{{url_for('containers',container_id=container.Id)}}">
{{container.Labels["com.docker.compose.project"]}}/{{container.Labels["com.docker.compose.service"]}}
</a>
</td>
<td>
<a href="{{urljoin('https://hub.docker.com/r/',container.Image)}}">{{container.Image}}</a>
</td>
<td>
{{container.Status}}
</td>
</tr>
{% endfor %}
</table>
{% endblock %}

65
templates/history.html Normal file
View file

@ -0,0 +1,65 @@
{%- extends "base.html" -%}
{%- from 'utils.html' import make_tabs -%}
{%- macro default(event,source) -%}
<h5>Unknown ({{source}})</h5>
<pre>{{event|tojson(indent=4)}}</pre>
{%- endmacro -%}
{%- macro downloadFolderImported(event,source) -%}
[{{event.seriesId}}/{{event.episodeId}}] Imported {{event.data.droppedPath}} from {{event.data.downloadClientName}} to {{event.data.importedPath}}
{%- endmacro -%}
{%- macro grabbed(event,source) -%}
[{{event.seriesId}}/{{event.episodeId}}] Grabbed <a href="{{event.data.guid}}">{{event.sourceTitle}}</a>
{%- endmacro -%}
{%- macro episodeFileDeleted(event,source) -%}
[{{event.seriesId}}/{{event.episodeId}}] Deleted {{event.sourceTitle}} because {{event.data.reason}}
{%- endmacro -%}
{%- macro episodeFileRenamed(event,source) -%}
[{{event.seriesId}}/{{event.episodeId}}] Renamed {{event.data.sourcePath}} to {{event.data.path}}
{%- endmacro -%}
{%- macro movieFileDeleted(event,source) -%}
Renamed {{event.data.sourcePath}} to {{event.data.path}}
{%- endmacro -%}
{%- macro movieFileRenamed(event,source) -%}
<h5>renamed</h5>
<pre>{{event|tojson(indent=4)}}</pre>
{%- endmacro -%}
{%- macro downloadFailed(event,source) -%}
<h5>downloadFailed</h5>
<pre>{{event|tojson(indent=4)}}</pre>
{%- endmacro -%}
{%- set handlers = {
'downloadFolderImported': downloadFolderImported,
'grabbed': grabbed,
'episodeFileDeleted': episodeFileDeleted,
'episodeFileRenamed': episodeFileRenamed,
'movieFileDeleted': movieFileDeleted,
'movieFileRenamed': movieFileRenamed,
'downloadFailed': downloadFailed,
None: default
} -%}
{%- macro history_page(history,source) -%}
<pre>
{%- for entry in history.records -%}
{{handlers.get(entry.eventType,handlers[None])(entry,source)}}{{'\n'}}
{%- endfor -%}
</pre>
{%- endmacro -%}
{%- block app_content -%}
<h2>History</h2>
<div class="row">
<div class="col">
{{make_tabs([('Sonarr',history_page(sonarr,'sonarr')),('Radarr',history_page(radarr,'radarr'))])}}
</div>
</div>
{%- endblock -%}

122
templates/index.html Normal file
View file

@ -0,0 +1,122 @@
{% extends "base.html" %}
{% macro make_row(title,items) %}
<div class="d-flex flex-wrap">
{% for item in items %}
{{item|safe}}
{% endfor %}
</div>
{% endmacro %}
{% macro make_tabs(tabs) %}
<div class="row">
<div class="col">
<ul class="nav nav-pills mb-3" id="pills-tab" role="tablist">
{% for (label,_) in tabs %}
{% set slug = (label|slugify) %}
{% if not (loop.first and loop.last) %}
<li class="nav-item">
<a class="nav-link {{'active' if loop.first}}" id="nav-{{slug}}-tab" data-toggle="pill" href="#pills-{{slug}}" role="tab" aria-controls="pills-{{slug}}" aria-selected="{{loop.first}}">
{{label}}
</a>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
</div>
<div class="tab-content" id="searchResults">
{% for (label,items) in tabs %}
{% set slug = (label|slugify) %}
<div class="tab-pane fade {{'show active' if loop.first}}" id="pills-{{slug}}" role="tabpanel" aria-labelledby="nav-{{slug}}-tab">
{{make_row(label,items)}}
</div>
{% endfor %}
</div>
{% endmacro %}
{% macro upcoming(data) %}
<div class="container">
<div class="row">
<div class="col-lg">
<h3>Movies</h3>
<table class="table table-sm">
<tr>
<th>Title</th>
<th>In Cinemas</th>
<th>Digital Release</th>
</tr>
{% for movie in data.calendar.movies %}
{% if movie.isAvailable and movie.hasFile %}
{% set row_class = "bg-success" %}
{% elif movie.isAvailable and not movie.hasFile %}
{% set row_class = "bg-danger" %}
{% elif not movie.isAvailable and movie.hasFile %}
{% set row_class = "bg-primary" %}
{% elif not movie.isAvailable and not movie.hasFile %}
{% set row_class = "bg-info" %}
{% endif %}
<tr class={{row_class}}>
<td>
<a href="{{urljoin(config.APP_CONFIG.radarr_url,'movie/'+movie.titleSlug)}}" style="color: #eee; text-decoration: underline;">
{{movie.title}}
</a>
</td>
<td>{{movie.inCinemas|fromiso|ago_dt_utc_human(rnd=0)}}</td>
<td>{{movie.digitalRelease|fromiso|ago_dt_utc_human(rnd=0)}}</td>
</tr>
{% endfor %}
</table>
<h3>Episodes</h3>
<table class="table table-sm">
<tr>
<th>Season | Episode Number</th>
<th>Show</th>
<th>Title</th>
<th>Air Date</th>
</tr>
{% for entry in data.calendar.episodes %}
{% if entry.episode.hasAired and entry.episode.hasFile %}
{% set row_class = "bg-success" %}
{% elif entry.episode.hasAired and not entry.episode.hasFile %}
{% set row_class = "bg-danger" %}
{% elif not entry.episode.hasAired and entry.episode.hasFile %}
{% set row_class = "bg-primary" %}
{% elif not entry.episode.hasAired and not entry.episode.hasFile %}
{% set row_class = "bg-info" %}
{% endif %}
<tr class={{row_class}}>
<td>{{entry.episode.seasonNumber}} | {{entry.episode.episodeNumber}}</td>
<td>
<a href="{{urljoin(config.APP_CONFIG.sonarr_url,'series/'+entry.series.titleSlug)}}" style="color: #eee; text-decoration: underline;">
{{entry.series.title}}
</a>
</td>
<td>{{entry.episode.title}}</td>
<td>{{entry.episode.airDateUtc|fromiso|ago_dt_utc_human(rnd=0)}}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
</div>
{% endmacro %}
{% block app_content %}
{% if data is none %}
<h2>No Data available!</h2>
{% else %}
{% set tabs = [] %}
{% do tabs.append(("Upcoming",[upcoming(data)])) %}
{% for row in data.images %}
{% if row[0] is string %}
{% set title=row[0] %}
{% set row=row[1:] %}
{% do tabs.append((title,row)) %}
{% endif %}
{% endfor %}
{{make_tabs(tabs)}}
{% endif %}
{% endblock %}

View file

@ -0,0 +1,121 @@
{% extends "base.html" %}
{% from 'utils.html' import custom_render_form_row,make_tabs %}
{% from 'bootstrap/utils.html' import render_icon %}
{% from 'bootstrap/form.html' import render_form, render_field, render_form_row %}
{% block app_content %}
<h2><a href={{jellyfin.info.LocalAddress}}>Jellyfin</a> v{{jellyfin.info.Version}}</h2>
<div class="row">
<div class="col-lg">
<h4>Active Streams</h4>
<table class="table table-sm">
<tr>
<th>Episode</th>
<th>Show</th>
<th>Language</th>
<th>User</th>
<th>Device</th>
<th>Mode</th>
</tr>
{% for session in jellyfin.sessions %}
{% if "NowPlayingItem" in session %}
{% with np=session.NowPlayingItem, ps=session.PlayState%}
<tr>
<td>
{% if session.SupportsMediaControl %}
<a href="{{url_for('stop_stream',session=session.Id)}}">
{{render_icon("stop-circle")}}
</a>
{% endif %}
<a href="{{cfg().jellyfin_url}}web/index.html#!/details?id={{np.Id}}">
{{np.Name}}
</a>
({{(ps.PositionTicks/10_000_000)|timedelta(digits=0)}}/{{(np.RunTimeTicks/10_000_000)|timedelta(digits=0)}})
{% if ps.IsPaused %}
(Paused)
{% endif %}
</td>
<td>
<a href="{{cfg().jellyfin_url}}web/index.html#!/details?id={{np.SeriesId}}">
{{np.SeriesName}}
</a>
<a href="{{cfg().jellyfin_url}}web/index.html#!/details?id={{np.SeasonId}}">
({{np.SeasonName}})
</a>
</td>
<td>
{% if ("AudioStreamIndex" in ps) and ("SubtitleStreamIndex" in ps) %}
{{np.MediaStreams[ps.AudioStreamIndex].Language or "None"}}/{{np.MediaStreams[ps.SubtitleStreamIndex].Language or "None"}}
{% else %}
Unk/Unk
{% endif %}
</td>
<td>
<a href="{{cfg().jellyfin_url}}web/index.html#!/useredit.html?userId={{session.UserId}}">
{{session.UserName}}
</a>
</td>
<td>
{{session.DeviceName}}
</td>
<td>
{% if ps.PlayMethod =="Transcode" %}
<p title="{{session.TranscodingInfo.Bitrate|filesizeformat(binary=False)}}/s | {{session.TranscodingInfo.CompletionPercentage|round(2)}}%">
{{ps.PlayMethod}}
</p>
{% else %}
<p>
{{ps.PlayMethod}}
</p>
{% endif %}
</td>
</tr>
{% endwith %}
{% endif %}
{% endfor %}
</table>
</div>
</div>
<div class="row">
<div class="col-lg">
<h4>Users</h4>
<table class="table table-sm">
<tr>
<th>Name</th>
<th>Last Login</th>
<th>Last Active</th>
<th>Bandwidth Limit</th>
</tr>
{% for user in jellyfin.users|sort(attribute="LastLoginDate",reverse=True) %}
<tr>
<td>
<a href="{{cfg().jellyfin_url}}web/index.html#!/useredit.html?userId={{user.Id}}">
{{user.Name}}
</a>
</td>
<td>
{% if "LastLoginDate" in user %}
{{user.LastLoginDate|fromiso|ago_dt_utc(2)}} ago
{% else %}
Never
{% endif %}
</td>
<td>
{% if "LastActivityDate" in user %}
{{user.LastActivityDate|fromiso|ago_dt_utc(2)}} ago
{% else %}
Never
{% endif %}
</td>
<td>{{user.Policy.RemoteClientBitrateLimit|filesizeformat(binary=False)}}/s</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% endblock %}

39
templates/logs.html Normal file
View file

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block app_content %}
<div class="row">
<h2>QBittorrent</h2>
<div class="monospace">
{% set t_first = logs.qbt[0].timestamp %}
{% for message in logs.qbt if "WebAPI login success" not in message.message %}
{%set type={1: 'status' , 2: 'info', 4: 'warning', 8:'danger'}.get(message.type,none) %}
{%set type_name={1: 'NORMAL' , 2: 'INFO', 4: 'WARNING', 8:'CRITICAL'}.get(message.type,none) %}
<p class="text-{{type}}">
[{{((message.timestamp-t_first)/1000) | timedelta}}|{{type_name}}] {{message.message.strip()}}
</p>
{% endfor %}
</div>
<h2>Sonarr</h2>
<div class="monospace">
{% set t_first = (logs.sonarr.records[0].time)|fromiso %}
{% for message in logs.sonarr.records %}
{%set type={'warn': 'warning', 'error':'danger'}.get(message.level,message.level) %}
<p class="text-{{type}}">
[{{message.time | fromiso | ago_dt}}|{{message.logger}}|{{message.level|upper}}] {{message.message.strip()}}
</p>
{% endfor %}
</div>
<h2>Radarr</h2>
<div class="monospace">
{% set t_first = (logs.radarr.records[0].time)|fromiso %}
{% for message in logs.radarr.records %}
{%set type={'warn': 'warning', 8:'danger'}.get(message.level,message.level) %}
<p class="text-{{type}}">
[{{message.time | fromiso | ago_dt}}|{{message.logger}}|{{message.level|upper}}] {{message.message.strip()}}
</p>
{% endfor %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,236 @@
{% extends "base.html" %}
{% from "utils.html" import render_tree %}
{% block scripts %}
{{super()}}
<script lang="text/javascript">
var toggler = document.getElementsByClassName("custom_caret");
var i;
for (i = 0; i < toggler.length; i++) {
toggler[i].addEventListener("click", function () {
this.parentElement.querySelector(".nested").classList.toggle("active");
this.classList.toggle("custom_caret-down");
});
}
</script>
{% endblock %}
{% block app_content %}
<div class="row">
<div class="col">
<h1>
<a href="{{qbt.info.magnet_uri}}" title="{{qbt.info.hash}}">{{qbt.info.name}}</a>
</h1>
</div>
</div>
<div class="row">
<div class="col">
<div class="progress" style="width: 100%;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
style="width: {{(qbt.info.progress*100)|round(2)}}%;" role="progressbar"
aria-valuenow="{{(qbt.info.progress*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100">
{{(qbt.info.progress*100)|round(2)}}&nbsp;%
</div>
<div class="progress-bar progress-bar-striped progress-bar-animated bg-primary"
style="width: {{((([qbt.info.availability,1]|min)-qbt.info.progress)*100)|round(2)}}%;" role="progressbar"
aria-valuenow="{{((([qbt.info.availability,1]|min)-qbt.info.progress)*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100">
</div>
</div>
</div>
</div>
<div class="row">
<div class="col">
<span class="badge badge-{{qbt.info.state[1]}}">
{{qbt.info.state[0]}}
</span>
{% if qbt.info.category %}
<span class="badge badge-light">{{qbt.info.category}}</span>
{% endif %}
</div>
</div>
<h2>Info</h2>
<div class="row">
<div class="col">
Total Size
</div>
<div class="col">
{{qbt.info.size|filesizeformat(binary=True)}} ({{[0,qbt.info.size-qbt.info.downloaded]|max|filesizeformat(binary=True)}} left)
</div>
<div class="col">
Files
</div>
<div class="col">
{{qbt.files|count}}
</div>
</div>
<div class="row">
<div class="col">
Downloaded
</div>
<div class="col">
{{qbt.info.downloaded|filesizeformat(binary=True)}} ({{qbt.info.dlspeed|filesizeformat(binary=True)}}/s)
</div>
<div class="col">
Uploaded
</div>
<div class="col">
{{qbt.info.uploaded|filesizeformat(binary=True)}} ({{qbt.info.upspeed|filesizeformat(binary=True)}}/s)
</div>
</div>
<h2>Health</h2>
<div class="row">
<div class="col">
Last Active
</div>
<div class="col">
{{qbt.info.last_activity|ago(clamp=True)}} Ago
</div>
<div class="col">
Age
</div>
<div class="col">
{{qbt.info.added_on|ago}}
</div>
</div>
<div class="row">
<div class="col">
Avg. DL rate
</div>
<div class="col">
{{(qbt.info.downloaded/((qbt.info.added_on|ago).total_seconds()))|filesizeformat(binary=True)}}/s
(A: {{(qbt.info.downloaded/qbt.info.time_active)|filesizeformat(binary=True)}}/s)
</div>
<div class="col">
Avg. UL rate
</div>
<div class="col">
{{(qbt.info.uploaded/((qbt.info.added_on|ago).total_seconds()))|filesizeformat(binary=True)}}/s
(A: {{(qbt.info.uploaded/qbt.info.time_active)|filesizeformat(binary=True)}}/s)
</div>
</div>
<div class="row">
<div class="col">
ETC (DL rate while active)
</div>
<div class="col">
{% set dl_rate_act = (qbt.info.downloaded/qbt.info.time_active) %}
{% if dl_rate_act>0 %}
{{((qbt.info.size-qbt.info.downloaded)/dl_rate_act)|round(0)|timedelta(clamp=true)}}
{% else %}
N/A
{% endif %}
</div>
<div class="col">
ETC (avg. DL rate)
</div>
<div class="col">
{% set dl_rate = (qbt.info.downloaded/((qbt.info.added_on|ago(clamp=True)).total_seconds())) %}
{% if dl_rate>0 %}
{{((qbt.info.size-qbt.info.downloaded)/dl_rate)|round(0)|timedelta(clamp=true)}}
{% else %}
N/A
{% endif %}
</div>
</div>
<div class="row">
<div class="col">
Total active time
</div>
<div class="col">
{{qbt.info.time_active|timedelta}}
</div>
<div class="col">
Availability
</div>
<div class="col">
{% if qbt.info.availability==-1 %}
N/A
{% else %}
{{(qbt.info.availability*100)|round(2)}}&nbsp;%
{% endif %}
</div>
</div>
<h2>Swarm</h2>
<div class="row">
<div class="col">
Seeds
</div>
<div class="col">
{{qbt.info.num_seeds}}
</div>
<div class="col">
Leechers
</div>
<div class="col">
{{qbt.info.num_leechs}}
</div>
</div>
<div class="row">
<div class="col">
Last seen completed
</div>
<div class="col">
{{qbt.info.seen_complete|ago}} Ago
</div>
<div class="col"></div>
<div class="col"></div>
</div>
<h2>Files</h2>
{{render_tree(qbt.files|sort(attribute='name')|list|make_tree)}}
<div class="row">
<div class="col">
<h2>Trackers</h2>
<a href="{{url_for('qbittorent_add_trackers',infohash=qbt.info.hash)}}">
<span class="badge badge-primary">Add default trackers</span>
</a>
</div>
</div>
{% for tracker in qbt.trackers|sort(attribute='total_peers', reverse=true) %}
<div class="row">
<div class="col">
{% if tracker.has_url %}
<a href="{{tracker.url}}">{{tracker.name}}</a>
{% else %}
{{tracker.name}}
{% endif %}
{% if tracker.message %}
<code>{{tracker.message}}</code>
{% endif %}
</div>
<div class="col">
<span class="badge badge-{{tracker.status[1]}}">{{tracker.status[0]}}</span>
(S: {{tracker.num_seeds[1]}}, L: {{tracker.num_leeches[1]}}, P: {{tracker.num_peers[1]}}, D: {{tracker.num_downloaded[1]}})
</div>
</div>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,138 @@
{% extends "base.html" %}
{% macro torrent_entry(torrent) %}
{% set state_label,badge_type = status_map[torrent.state] or (torrent.state,'light') %}
<li class="list-group-item">
<a href="{{url_for('qbittorrent_details',infohash=torrent.hash)}}">{{torrent.name|truncate(75)}}</a>
(DL: {{torrent.dlspeed|filesizeformat(binary=true)}}/s, UL: {{torrent.upspeed|filesizeformat(binary=true)}}/s)
<span class="badge badge-{{badge_type}}">{{state_label}}</span>
{% if torrent.category %}
<span class="badge badge-light">{{torrent.category}}</span>
{% endif %}
<div style="margin-top: 5px"></div>
<div class="progress" style="width: 100%;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
style="width: {{(torrent.progress*100)|round(2)}}%;" role="progressbar"
aria-valuenow="{{(torrent.progress*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100">
</div>
<div class="progress-bar progress-bar-striped progress-bar-animated bg-primary"
style="width: {{((([torrent.availability,1]|min)-torrent.progress)*100)|round(2)}}%;" role="progressbar"
aria-valuenow="{{((([torrent.availability,1]|min)-torrent.progress)*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100">
</div>
<small class="justify-content-center d-flex position-absolute w-100">{{(torrent.progress*100)|round(2)}}&nbsp;% (ETA: {{[torrent.eta,torrent.eta_act]|min|round(0)|timedelta(clamp=true)}})</small>
</div>
</li>
{% endmacro %}
{% block app_content %}
<h2>
<a href="{{config.APP_CONFIG.qbt_url}}">QBittorrent</a>
{{qbt.version}}
(DL: {{qbt.server_state.dl_info_speed|filesizeformat(binary=True)}}/s,
UL: {{qbt.server_state.up_info_speed|filesizeformat(binary=True)}}/s)
</h2>
<div class="row">
<div class="col">
Total Uploaded
</div>
<div class="col">
{{qbt.server_state.alltime_ul|filesizeformat(binary=True)}}
</div>
<div class="col">
Total Downloaded
</div>
<div class="col">
{{qbt.server_state.alltime_dl|filesizeformat(binary=True)}}
</div>
</div>
<div class="row">
<div class="col">
Session Uploaded
</div>
<div class="col">
{{qbt.server_state.up_info_data|filesizeformat(binary=True)}}
</div>
<div class="col">
Session Downloaded
</div>
<div class="col">
{{qbt.server_state.dl_info_data|filesizeformat(binary=True)}}
</div>
</div>
<div class="row">
<div class="col">
Torrents
</div>
<div class="col">
{{qbt.torrents|length}}
</div>
<div class="col">
Total Queue Size
</div>
<div class="col">
{{qbt.torrents.values()|map(attribute='size')|sum|filesizeformat(binary=true)}}
</div>
</div>
<hr />
<div class="row">
<div class="col">
<form method="GET">
<select class="form-control" name="sort" onchange="this.parentElement.submit()">
<option value="">Sort by</option>
{% for key,value in sort_by_choices.items() %}
<option value="{{key}}">{{value}}</option>
{% endfor %}
</select>
</form>
</div>
</div>
{% for state,torrents in qbt.torrents.values()|sort(attribute='state')|groupby('state') %}
{% set state_label,badge_type = status_map[state] or (state,'light') %}
<div class="row">
<div class="col">
<a href={{url_for("qbittorrent",state=state)}} >{{state_label}}</a>
</div>
<div class="col">
{{torrents|length}}
</div>
</div>
{% endfor %}
{% if state_filter %}
<div class="row">
<div class="col">
<a href={{url_for("qbittorrent")}}>[Clear filter]</a>
</div>
<div class="col">
</div>
</div>
{% endif %}
<hr />
<div class="row">
<div class="col">
<ul style="padding-bottom: 10px;" class="list-group">
{% for torrent in qbt.torrents.values()|sort(attribute=sort_by,reverse=True) %}
{% set state_label,badge_type = status_map[torrent.state] or (torrent.state,'light') %}
{% if state_filter %}
{% if torrent.state==state_filter %}
{{torrent_entry(torrent)}}
{% endif %}
{% else %}
{{torrent_entry(torrent)}}
{% endif %}
{% endfor %}
</ul>
</div>
</div>
{% endblock %}

View file

View file

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% from 'utils.html' import make_tabs %}
{% macro movie_list() %}
{% for movie in movies|sort(attribute='sortTitle') %}
<h6>
<a href="{{urljoin(config.APP_CONFIG.radarr_url,'movie/'+movie.titleSlug)}}">{{movie.title}}</a>
({{movie.year}})
{% for genre in movie.genres %}
<span class="badge badge-secondary">{{genre}}</span>
{% endfor %}
<span class="badge badge-info">{{movie.status|title}}</span>
</h6>
{% endfor %}
{% endmacro %}
{% block app_content %}
<h2>
<a href="{{config.APP_CONFIG.radarr_url}}">Radarr</a>
v{{status.version}} ({{movies|count}} Movies)
</h2>
<div class="row">
<div class="col">
{{movie_list()}}
</div>
</div>
{% endblock %}

23
templates/remote/add.html Normal file
View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% from 'utils.html' import custom_render_form_row,make_tabs %}
{% from 'bootstrap/form.html' import render_form, render_field, render_form_row %}
{% block app_content %}
{% if form %}
<h1>Grant remote access</h1>
{% endif %}
<div class="row">
<div class="col-lg">
<form method="post" class="form">
{{form.csrf_token()}}
{{custom_render_form_row([form.name])}}
{{custom_render_form_row([form.ssh_key])}}
{{custom_render_form_row([form.add])}}
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,78 @@
{% extends "base.html" %}
{% from 'utils.html' import custom_render_form_row,make_tabs %}
{% from 'bootstrap/utils.html' import render_icon %}
{% from 'bootstrap/form.html' import render_form, render_field, render_form_row %}
{% block app_content %}
<h1>
Remote access <a href={{url_for("remote_add")}}>{{render_icon("person-plus-fill")}}</a>
</h1>
<div class="row">
<div class="col-lg">
<h4>SSH</h4>
<table class="table table-sm">
<tr>
<th></th>
<th>Type</th>
<th>Key fingerprint</th>
<th>Name</th>
</tr>
{% for key in ssh %}
<tr {{ {"class":"text-muted" if key.disabled else none}|xmlattr }}>
<td>
{% if key.disabled %}
<a href="{{url_for("remote",enabled=True,key=key.key)}}">{{render_icon("person-x-fill",color='danger')}}</a>
{% else %}
<a href="{{url_for("remote",enabled=False,key=key.key)}}">{{render_icon("person-check-fill",color='success')}}</a>
{% endif %}
</td>
<td>{{key.type}}</td>
<td title="{{key.key}}">{{key.fingerprint}}</td>
<td>{{key.name}}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="row">
<div class="col-lg">
<h4><a href="{{cfg().jellyfin_url}}web/index.html#!/userprofiles.html">Jellyfin</a></h4>
<table class="table table-sm">
<tr>
<th>Name</th>
<th>Last Login</th>
<th>Last Active</th>
<th>Bandwidth Limit</th>
</tr>
{% for user in jf|sort(attribute="LastLoginDate",reverse=True) %}
<tr>
<td>
<a href="{{cfg().jellyfin_url}}web/index.html#!/useredit.html?userId={{user.Id}}">
{{user.Name}}
</a>
</td>
<td>
{% if "LastLoginDate" in user %}
{{user.LastLoginDate|fromiso|ago_dt_utc(2)}} ago
{% else %}
Never
{% endif %}
</td>
<td>
{% if "LastActivityDate" in user %}
{{user.LastActivityDate|fromiso|ago_dt_utc(2)}} ago
{% else %}
Never
{% endif %}
</td>
<td>{{user.Policy.RemoteClientBitrateLimit|filesizeformat(binary=False)}}/s</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block app_content %}
<h1>{{info.title}} ({{info.year}})</h1>
<h2>{{info.hasFile}}</h2>
<p>{{info.id}}</p>
<pre>
{{info|tojson(indent=4)}}
</pre>
{% endblock %}

View file

@ -0,0 +1,18 @@
{% macro movie_results(results) -%}
<div class="d-flex flex-wrap">
{%for result in results %}
<form action="search/details" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="type" value="movie"/>
<input type="hidden" name="data" value="{{result|tojson|urlencode}}"/>
<a style="cursor: pointer" onclick="this.parentElement.submit()">
{% for poster in result.images|selectattr('coverType','eq','poster') %}
<img class="img-fluid poster" src="{{poster.url}}" title="{{result.title}}">
{% else %}
<img class="img-fluid poster" src="{{url_for('placeholder',width=333, height=500, text=result.title, wrap = 15)}}" title="{{result.title}}" />
{% endfor %}
</a>
</form>
{% endfor %}
</div>
{% endmacro %}

View file

@ -0,0 +1,123 @@
{% macro torrent_result_row(result,with_tracker=false) -%}
<tr>
<td colspan="{{4 if with_tracker else 3}}">
<hr/>
</td>
</tr>
<tr>
<td colspan="{{4 if with_tracker else 3}}">
<input type="checkbox" id="torrent_selected" name="torrent[]" value="{{result.Link or result.MagnetUri}}">
<a href="{{result.Link or result.MagnetUri}}">
{{result.Title}}
</a>
{% if result.DownloadVolumeFactor==0.0 %}
<span class="badge badge-success">Freeleech</span>
{% endif %}
{% if result.UploadVolumeFactor > 1.0 %}
<span class="badge badge-success">UL x{{result.UploadVolumeFactor}}</span>
{% endif %}
</td>
</tr>
<tr>
<td>
{{result.CategoryDesc}}
</td>
<td>
{{result.Size|filesizeformat}}
</td>
{% if with_tracker %}
<td>
<a href="{{result.Guid}}">
{{result.Tracker}}
</a>
</td>
{% endif %}
<td>
({{result.Seeders}}/{{result.Peers}}/{{ "?" if result.Grabs is none else result.Grabs}})
</td>
</tr>
{% endmacro %}
{% macro torrent_result_grouped(results) %}
{% if results %}
<table class="torrent_results">
{% for tracker,results in results.Results|groupby(attribute="Tracker") %}
<thead>
<tr>
<th>
<h2>{{tracker}} ({{results|length}})</h2>
</th>
</tr>
<tr>
<th colspan="{{4 if with_tracker else 3}}">
Name
</th>
</tr>
<tr>
<th>
Category
</th>
<th>
Size
</th>
<th>
Seeds/Peers/Grabs
</th>
</tr>
</thead>
{%for result in results|sort(attribute='Gain',reverse=true) %}
{{ torrent_result_row(result,with_tracker=false) }}
{% endfor %}
{% endfor %}
<tr>
<td colspan="{{4 if with_tracker else 3}}">
<hr/>
</td>
</tr>
</table>
{% endif %}
{% endmacro %}
{% macro torrent_results(results,group_by_tracker=false) %}
<form action="/api/add_torrent" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="text" class="form-control" id="category" name="category" placeholder="Category"/>
<span class="badge badge-primary" style="cursor: pointer" onclick="this.parentElement.submit()">
Add selected to QBittorrent
</span>
{% if group_by_tracker %}
{{ torrent_result_grouped(results) }}
{% else %}
{% if results %}
<table class="torrent_results">
<thead>
<tr>
<th colspan="{{4 if with_tracker else 3}}">
Name
</th>
</tr>
<tr>
<th>
Category
</th>
<th>
Size
</th>
<th>
Tracker
</th>
<th>
Seeds/Peers/Grabs
</th>
</tr>
</thead>
{% for result in results.Results|sort(attribute='Gain',reverse=true) %}
{{ torrent_result_row(result,with_tracker=true) }}
{% endfor %}
</table>
{% endif %}
{% endif %}
</form>
{% endmacro %}

View file

@ -0,0 +1,23 @@
{% macro tv_show_results(results) -%}
<div class="d-flex flex-wrap">
{% for result in results %}
<form action="search/details" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="type" value="show"/>
<input type="hidden" name="data" value="{{result|tojson|urlencode}}" />
<a style="cursor: pointer" onclick="this.parentElement.submit()">
{% for banner in result.images|selectattr('coverType','eq','banner') %}
<img class="img-fluid banner" src="{{client.sonarr.url.rsplit("/",2)[0]+banner.url}}" title="{{result.title}}" />
{% else %}
{% set poster=(result.images|selectattr('coverType','eq','poster')|first) %}
{% if poster %}
{% set poster=(client.sonarr.url.rsplit("/",2)[0]+poster.url) %}
{% endif %}
<img class="img-fluid banner" src="{{url_for('placeholder',width=758, height=140, poster=poster, text=result.title)}}" title="{{result.title}}" />
{% endfor %}
</a>
</form>
{% endfor %}
</div>
{% endmacro %}

View file

@ -0,0 +1,64 @@
{% extends "base.html" %}
{% from "utils.html" import make_tabs, custom_render_form_row %}
{% from 'bootstrap/form.html' import render_form, render_field, render_form_row %}
{% from 'bootstrap/table.html' import render_table %}
{% from 'search/include/tv_show.html' import tv_show_results with context %}
{% from 'search/include/movie.html' import movie_results with context %}
{% from 'search/include/torrent.html' import torrent_results with context %}
{% block styles %}
{{super()}}
<style>
.poster {
height: 500px;
object-fit: cover;
}
</style>
{% endblock %}
{% block app_content %}
{% if form %}
<h1>Search</h1>
{% endif %}
<div class="row">
<div class="col-lg">
{% if session.new_torrents %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
{% for torrent in session.pop('new_torrents',{}).values() %}
<p>
Added <a class="alert-link" href="{{url_for('qbittorrent',infohash=torrent.hash)}}">{{torrent.name}}</a>
</p>
{% endfor %}
</div>
{% endif %}
{% if form %}
<form method="post" class="form">
{{form.csrf_token()}}
{{custom_render_form_row([form.query],render_args={'form_type':'horizontal','horizontal_columns':('lg',1,11)})}}
{{custom_render_form_row([form.tv_shows,form.movies,form.torrents])}}
{{custom_render_form_row([form.group_by_tracker])}}
{{custom_render_form_row([form.indexer])}}
{{custom_render_form_row([form.search])}}
</form>
{% else %}
<h1>Search results for '{{search_term}}'</h1>
{% endif %}
</div>
</div>
{% set search_results = [
(results.tv_shows,"tv","TV Shows",tv_show_results,{}),
(results.movies,"movie","Movies",movie_results,{}),
(results.torrents,"torrent","Torrents",torrent_results,{"group_by_tracker":group_by_tracker}),
] %}
{% if results %}
{% set tabs = [] %}
{% for results,id_name,label,func,kwargs in search_results if results %}
{% do tabs.append((label,func(results,**kwargs))) %}
{% endfor %}
{{make_tabs(tabs)}}
{% endif %}
{% endblock %}

View file

View file

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% from 'utils.html' import make_tabs %}
{% macro series_list() %}
{% for show in series|sort(attribute='sortTitle') %}
<h6>
<a href="{{urljoin(config.APP_CONFIG.sonarr_url,'series/'+show.titleSlug)}}">{{show.title}}</a>
({{show.year}})
{% for genre in show.genres %}
<span class="badge badge-secondary">{{genre}}</span>
{% endfor %}
<span class="badge badge-info">{{show.status|title}}</span>
</h6>
{% endfor %}
{% endmacro %}
{% block app_content %}
<h2>
<a href="{{config.APP_CONFIG.sonarr_url}}">Sonarr</a>
v{{status.version}} ({{series|count}} Shows)
</h2>
<div class="row">
<div class="col">
{{series_list()}}
</div>
</div>
{% endblock %}

43
templates/test.html Normal file
View file

@ -0,0 +1,43 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %}
{% block scripts %}
{{super()}}
<script lang="text/javascript">
let prog=0;
function setPrograb(selector,value) {
let rv=Math.round(prog*100,2)/100;
$(selector).attr('style','width: '+rv+"%;");
$(selector).attr('aria-valuenow',rv);
$(selector).text(rv+' %');
}
setInterval(() => {
for (var i=0;i<100;++i) {
prog=Math.random()*100;
setPrograb("#prog_test_bar_"+i,prog);
}
},1000)
</script>
{% endblock %}
{% block app_content__ %}
<div class="row">
{{render_form(form)}}
</div>
{% endblock %}
{% block app_content %}
{% for i in range(100) %}
<div class="row">
<div id="prog_test_{{i}}" class="progress" style="width: 100%;">
<div id="prog_test_bar_{{i}}" class="progress-bar progress-bar-striped progress-bar-animated"
style="width: 0%;" role="progressbar"
aria-valuenow="" aria-valuemin="0" aria-valuemax="100">
</div>
</div>
</div>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% from 'utils.html' import make_tabs %}
{% from 'bootstrap/form.html' import render_form, render_field, render_form_row %}
{% macro profile_list() %}
{% for name, cfg in config.APP_CONFIG.transcode_profiles.items() %}
<h3>{{name}}</h3>
<h5>{{cfg.doc}}</h5>
<pre>ffmpeg -i &lt;infile&gt; {{cfg.command}} &lt;outfile&gt;</pre>
{% if cfg.vars %}
{% for var,doc in cfg.vars.items() %}
<p>
<pre class="inline">{{var}}</pre>
({{doc}}{% if cfg.defaults[var] %}, Default: <pre class="inline">{{cfg.defaults[var]}}</pre>{% endif %})</p>
{% endfor %}
{% endif %}
<hr>
{% endfor %}
{% endmacro %}
{% block app_content %}
<div class="row">
<div class="col">
<h1>Transcode profiles</h1>
{{profile_list()}}
</div>
</div>
{% endblock %}

85
templates/utils.html Normal file
View file

@ -0,0 +1,85 @@
{% from 'bootstrap/form.html' import render_field %}
{% macro custom_render_form_row(fields, row_class='form-row', col_class_default='col', col_map={}, button_map={}, button_style='', button_size='', render_args={}) %}
<div class="{{ row_class }}">
{% for field in fields %}
{% if field.name in col_map %}
{% set col_class = col_map[field.name] %}
{% else %}
{% set col_class = col_class_default %}
{% endif %}
<div class="{{ col_class }}">
{{ render_field(field, button_map=button_map, button_style=button_style, button_size=button_size, **render_args) }}
</div>
{% endfor %}
</div>
{% endmacro %}
{% macro make_tabs(tabs)%}
{% set tabs_id = tabs|tojson|hash %}
<div class="row">
<div class="col-lg">
<ul class="nav nav-pills mb-3" id="pills-tab" role="tablist">
{% for label,tab in tabs if tab %}
{% set id_name = [loop.index,tabs_id ]|join("-") %}
{% if not (loop.first and loop.last) %}
<li class="nav-item">
<a class="nav-link {{'active' if loop.first}}" id="nav-{{id_name}}-tab" data-toggle="pill" href="#pills-{{id_name}}" role="tab" aria-controls="pills-{{id_name}}" aria-selected="{{loop.first}}">
{{label}}
</a>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
</div>
<div class="row">
<div class="col-lg">
<div class="tab-content" id="searchResults">
{% for label,tab in tabs if tab %}
{% set id_name = [loop.index,tabs_id ]|join("-") %}
<div class="tab-pane fade {{'show active' if loop.first}}" id="pills-{{id_name}}" role="tabpanel" aria-labelledby="nav-{{id_name}}-tab">
{{ tab|safe }}
</div>
{% endfor %}
</div>
</div>
</div>
{% endmacro %}
{% macro render_tree(tree) -%}
<ul class="file_tree">
{% for node,children in tree.items() recursive %}
{% if node=="__info__" or not children is mapping -%}
{% set file = children %}
<li>
<div class="row" style="margin-left: 10px;">
<div class="progress" style="width: 100%;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
style="width: {{(file.progress*100)|round(2)}}%;" role="progressbar"
aria-valuenow="{{(file.progress*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100">
{{(file.progress*100)|round(2)}}&nbsp;% ({{file.size|filesizeformat(binary=True)}})
</div>
<div class="progress-bar progress-bar-striped progress-bar-animated bg-primary"
style="width: {{(((file.availability or 1) - file.progress)*100)|round(2)}}%;" role="progressbar"
aria-valuenow="{{(((file.availability or 1) - file.progress)*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100">
</div>
</div>
</div>
</li>
{% else -%}
<li class="tree">
<span class="{{'custom_caret' if children.items() else '' }}">
{{node}}
</span>
{% if children.items() -%}
<ul class="tree nested">
{{loop(children.items())}}
</ul>
{% endif %}
</li>
{% endif %}
{% endfor %}
</ul>
{% endmacro %}