Compare commits

...

3 Commits

Author SHA1 Message Date
032acb7664 add livestream check 2026-02-08 17:34:49 +00:00
4bb0c2966a Merge remote-tracking branch 'origin/master' into development 2026-02-08 16:57:11 +00:00
54a3bb8cc7 fix downloads 2026-01-27 18:49:21 +00:00
6 changed files with 147 additions and 5 deletions

48
app.py
View File

@ -27,6 +27,7 @@ import base64
import json
import io
import search
import urllib.request
import auth
import analytics as a
import folder_secret_config_editor as fsce
@ -461,6 +462,30 @@ def create_calendar_entry():
'details': details
}), 201
@app.route('/api/livestream/status', methods=['GET'])
@auth.require_secret
def livestream_status():
status_url = auth.return_app_config().get('LIVESTREAM_STATUS_URL')
result = {"audio_live": False, "live": False}
if not status_url:
reply = jsonify(result)
reply.headers['Cache-Control'] = 'no-store'
return reply
try:
with urllib.request.urlopen(status_url, timeout=5) as response:
if response.status != 200:
raise ValueError(f"Status endpoint returned {response.status}")
payload = response.read()
parsed = json.loads(payload.decode('utf-8'))
result["audio_live"] = bool(parsed.get("audio_live", False))
result["live"] = bool(parsed.get("live", False))
except Exception as err:
app.logger.warning("Livestream status proxy failed: %s", err)
reply = jsonify(result)
reply.headers['Cache-Control'] = 'no-store'
return reply
# Grab the HOST_RULE environment variable
host_rule = os.getenv("HOST_RULE", "")
# Use a regex to extract domain names between backticks in patterns like Host(`something`)
@ -782,9 +807,15 @@ def serve_file(subpath):
root, *relative_parts = subpath.split('/')
dltoken = request.args.get('dltoken')
token_payload = None
if dltoken:
as_attachment = True
full_path = auth.decode_token(dltoken)['filename']
try:
token_payload = auth.decode_token(dltoken)
full_path = token_payload['filename']
except Exception as e:
app.logger.warning(f"Invalid dltoken: {e}")
return jsonify({'Unauthorized': 'Invalid token'}), 403
else:
as_attachment = False
base_path = session['folders'].get(root)
@ -811,6 +842,8 @@ def serve_file(subpath):
user_agent = request.headers.get('User-Agent')
range_header = request.headers.get('Range', '')
req_id = request.args.get('req') or request.headers.get('X-Request-Id')
token_device_id = token_payload.get('device_id') if token_payload else None
device_id = token_device_id or session.get('device_id')
def is_range_prefetch(header, ua):
"""
@ -932,7 +965,7 @@ def serve_file(subpath):
mime,
ip_address,
user_agent,
session['device_id'],
device_id,
cached_hit,
request.method
)
@ -995,7 +1028,7 @@ def serve_file(subpath):
mime,
ip_address,
user_agent,
session['device_id'],
device_id,
cached_hit,
request.method
)
@ -1145,6 +1178,11 @@ def create_dltoken(subpath):
root, *relative_parts = subpath.split('/')
base_path = session['folders'].get(root)
full_path = os.path.join(base_path or '', *relative_parts)
device_id = session.get('device_id')
if not device_id:
device_id = os.urandom(32).hex()
session['device_id'] = device_id
try:
full_path = check_path(full_path)
@ -1158,7 +1196,8 @@ def create_dltoken(subpath):
validity_date = datetime.now().strftime('%d.%m.%Y')
data = {
"validity": validity_date,
"filename": str(full_path)
"filename": str(full_path),
"device_id": device_id
}
token = auth.generate_token(data)
@ -1275,6 +1314,7 @@ def index(path):
features=app_config.get('FEATURES', None),
og_title=og_title,
og_description=og_description,
livestream_url=app_config.get('LIVESTREAM_URL') or '',
admin_enabled=auth.is_admin()
)

View File

@ -354,6 +354,8 @@ KEY_MAP = {
"folders": "f",
"foldername": "n",
"folderpath": "p",
"filename": "fn",
"device_id": "d",
}
# Build the inverse map automatically
INV_KEY_MAP = {short: long for long, short in KEY_MAP.items()}

View File

@ -4,9 +4,11 @@
"ADMIN_KEY": "THIS_IS_USED_TO_AUTHENTICATE_ADMIN",
"TITLE_SHORT": "Gottesdienste",
"TITLE_LONG": "Gottesdienste App",
"LIVESTREAM_URL": "https://streaming.bethaus-speyer.de",
"LIVESTREAM_STATUS_URL": "https://streaming.bethaus-speyer.de/status",
"BASE_DIR": "/mnt",
"filecache_size_limit_audio": 16,
"filecache_size_limit_image": 16,
"filecache_size_limit_video": 16,
"filecache_size_limit_other": 16
}
}

View File

@ -128,6 +128,46 @@ main > * {
margin: 12px 0;
}
.livestream-alert {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.livestream-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 14px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.12);
border: 1px solid rgba(0, 0, 0, 0.2);
color: inherit;
font-weight: 600;
text-decoration: none;
transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}
.livestream-button:hover,
.livestream-button:focus-visible {
background: rgba(0, 0, 0, 0.2);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
.livestream-button:active {
transform: translateY(0);
box-shadow: none;
}
@media (max-width: 640px) {
.livestream-alert {
flex-direction: column;
align-items: flex-start;
}
}
/* Search view container (custom element) */
search {
display: block;

View File

@ -796,9 +796,60 @@ function initTranscriptModal() {
modal.dataset.bound = '1';
}
const LIVESTREAM_STATUS_URL = '/api/livestream/status';
const LIVESTREAM_POLL_INTERVAL_MS = 60000;
let livestreamStatusIntervalId = null;
let livestreamStatusErrorLogged = false;
function setLivestreamAlertVisible(isVisible) {
const alertEl = document.getElementById('livestream-alert');
if (!alertEl) return;
alertEl.classList.toggle('d-none', !isVisible);
alertEl.setAttribute('aria-hidden', String(!isVisible));
}
async function checkLivestreamStatus() {
const alertEl = document.getElementById('livestream-alert');
if (!alertEl) return;
try {
const response = await fetch(LIVESTREAM_STATUS_URL, {
cache: 'no-store',
credentials: 'same-origin',
headers: { 'Accept': 'application/json' }
});
if (!response.ok) throw new Error(`Livestream status request failed (${response.status})`);
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
const text = await response.text();
throw new Error(`Livestream status returned non-JSON: ${text.slice(0, 120)}`);
}
const data = await response.json();
const isLive = Boolean(data && (data.live || data.audio_live));
setLivestreamAlertVisible(isLive);
} catch (err) {
if (!livestreamStatusErrorLogged) {
console.warn('Livestream status check failed', err);
livestreamStatusErrorLogged = true;
}
setLivestreamAlertVisible(false);
}
}
function initLivestreamStatus() {
const alertEl = document.getElementById('livestream-alert');
if (!alertEl) return;
checkLivestreamStatus();
if (!livestreamStatusIntervalId) {
livestreamStatusIntervalId = setInterval(checkLivestreamStatus, LIVESTREAM_POLL_INTERVAL_MS);
}
}
function initAppPage() {
attachEventListeners();
initTranscriptModal();
initLivestreamStatus();
if (typeof window.initSearch === 'function') window.initSearch();
if (typeof window.initMessages === 'function') window.initMessages();
if (typeof window.initGallery === 'function') window.initGallery();

View File

@ -21,6 +21,13 @@
{% endblock %}
{% block content %}
<div class="container">
<div id="livestream-alert" class="alert alert-warning livestream-alert d-none" role="status" aria-live="polite">
<span>Zurzeit findet ein Livestream statt.</span>
<a class="alert-link livestream-button" id="livestream-link" href="{{ livestream_url }}" target="_blank" rel="noopener">Zum Livestream wechseln</a>
</div>
</div>
<!-- Tab Navigation -->
{% if features %}
<nav class="main-tabs">