Initial commit

Co-authored-by: GitHub Copilot <noreply@github.com>
This commit is contained in:
2026-09-03 11:42:09 +02:00
commit 76f5d30db1
19 changed files with 2357 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="pl" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<title>RadioSync Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--ink: #e9eef1;
--muted: #9ca9b2;
--canvas: #151a1e;
--surface: #20272c;
--surface-strong: #293238;
--line: #3b474e;
--cyan: #29d3e8;
--yellow: #ffd166;
--red: #ff6b6b;
--green: #5ee0a0;
}
* { box-sizing: border-box; }
body { background: var(--canvas); color: var(--ink); font-family: "DM Sans", sans-serif; letter-spacing: 0; }
h1, h2, h3, h4, h5, .navbar-brand { font-family: "Space Grotesk", sans-serif; letter-spacing: 0; }
.navbar { background: rgba(21, 26, 30, .96) !important; border-color: var(--line) !important; }
.navbar-brand { color: var(--cyan) !important; font-weight: 700; }
.navbar-brand-mark { color: var(--yellow); margin-right: .5rem; }
.nav-link { color: var(--muted) !important; font-weight: 600; }
.nav-link:hover, .nav-link.active { color: var(--cyan) !important; }
.btn { border-radius: 7px; font-weight: 600; }
.btn-primary { background: var(--cyan); border-color: var(--cyan); color: #102126; }
.btn-primary:hover { background: #7ae7f2; border-color: #7ae7f2; color: #102126; }
.btn-outline-info { color: var(--cyan); border-color: var(--cyan); }
.btn-outline-warning { color: var(--yellow); border-color: var(--yellow); }
.btn-light { background: var(--ink); border-color: var(--ink); color: #182025; }
.btn-danger { background: transparent; color: var(--red); border-color: var(--red); }
.surface { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; }
.section-label { color: var(--muted); font-size: .78rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.eyebrow { color: var(--cyan); font-size: .8rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.page-title { font-size: clamp(1.65rem, 3vw, 2.35rem); margin: 0; }
.log-box { height: 250px; overflow-y: auto; background: #111519; color: #b9c7ce; font-family: monospace; padding: 14px; font-size: 12px; border: 1px solid var(--line); border-radius: 8px; }
.podcast-img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; }
.form-control, .form-select { background: #181e22; border-color: var(--line); color: var(--ink); border-radius: 7px; }
.form-control:focus, .form-select:focus { background: #181e22; border-color: var(--cyan); color: var(--ink); box-shadow: 0 0 0 .2rem rgba(41, 211, 232, .14); }
.form-label { color: var(--muted); font-size: .82rem; font-weight: 600; }
.text-muted { color: var(--muted) !important; }
.table { --bs-table-bg: transparent; --bs-table-color: var(--ink); --bs-table-border-color: var(--line); }
.table thead th { color: var(--muted); font-size: .75rem; text-transform: uppercase; letter-spacing: .06em; }
.badge { font-weight: 700; }
.station-card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; height: 100%; }
.station-card .card-header { background: var(--surface-strong); border-bottom: 1px solid var(--line); border-radius: 11px 11px 0 0; }
.station-card .card-body { padding: 1rem 1.1rem; }
.station-card .card-footer { border-top: 1px solid var(--line) !important; }
.metric-label { color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; white-space: nowrap; }
.metric-value { font-size: 1.1rem; font-weight: 700; }
.config-surface { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; }
.config-surface .card-header { background: var(--surface-strong); border-color: var(--line); }
.error-item { background: var(--surface) !important; border-width: 1px !important; }
.error-item strong { font-family: "Space Grotesk", sans-serif; }
.program-card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; }
.program-card .card-body { padding: 1rem; }
.program-title { font-size: 1.05rem; line-height: 1.2; }
.program-card .card-footer { border-color: var(--line) !important; background: #1a2024 !important; }
.episode-item { background: var(--surface) !important; border: 1px solid var(--line) !important; border-radius: 10px !important; }
.episode-title { font-size: 1.05rem; }
.page-toolbar { border-bottom: 1px solid var(--line); padding-bottom: 1rem; margin-bottom: 1.25rem; }
.empty-state { color: var(--muted); padding: 2rem 0; }
@media (max-width: 767.98px) {
.container { padding-left: 18px; padding-right: 18px; }
.navbar .container { padding-left: 18px; padding-right: 18px; }
.page-title { font-size: 1.7rem; }
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom">
<div class="container">
<a class="navbar-brand" href="/"><span class="navbar-brand-mark">●</span>RadioSync Hub</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="/programs/radio357">357</a></li>
<li class="nav-item"><a class="nav-link" href="/programs/rns">Nowy Świat</a></li>
<li class="nav-item"><a class="nav-link" href="/programs/jazz">Jazz FM</a></li>
</ul>
<div class="d-flex">
<a href="/feeds/Podcasts.opml" class="btn btn-outline-warning btn-sm" target="_blank">Master OPML</a>
</div>
</div>
</div>
</nav>
<div class="container mt-4 mb-5">
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/programs/{{ program.station }}">{{ program.station }}</a></li>
<li class="breadcrumb-item active">{{ program.name }}</li>
</ol>
</nav>
{% if msg %}
<div class="alert alert-info alert-dismissible fade show" role="alert">
<strong>Info:</strong> {{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<div class="page-toolbar d-flex justify-content-between align-items-center gap-3">
<div><div class="eyebrow mb-2">Odcinki</div><h1 class="page-title">{{ program.name }}</h1></div>
<a href="/feeds/{{ program.station }}/{{ program.slug }}.xml" class="btn btn-warning" target="_blank">Skopiuj URL Feedu (RSS)</a>
</div>
<div class="list-group">
{% for ep in episodes %}
<div class="list-group-item episode-item list-group-item-dark text-white mb-2">
<div class="d-flex w-100 justify-content-between">
<h5 class="episode-title mb-1">{{ ep.title }}</h5>
<small>{{ ep.pub_date }}</small>
</div>
<p class="mb-1 small">{{ (ep.description or '')[:200] }}{% if ep.description and ep.description|length > 200 %}...{% endif %}</p>
<div class="d-flex justify-content-between align-items-center mt-3">
<div>
{% if ep.url %}
<audio controls preload="none" style="height: 35px; width: 300px;">
<source src="{{ ep.url }}" type="audio/mpeg">
Twoja przeglądarka nie obsługuje tagu audio.
</audio>
{% else %}
<span class="badge bg-danger">Brak pliku audio (lub oznaczony jako błąd)</span>
{% endif %}
</div>
{% if ep.url %}
<form method="POST" action="/episodes/delete-url" style="margin:0;">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input type="hidden" name="ep_id" value="{{ ep.id }}">
<button class="btn btn-danger btn-sm" onclick="return confirm('Czy na pewno chcesz usunąć ten link z bazy? Zostanie podjęta próba ponownego pobrania przy kolejnej synchronizacji.')">Usuń link audio</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}
+233
View File
@@ -0,0 +1,233 @@
{% extends "base.html" %}
{% block content %}
{% set is_running = (status.radio357.status == 'W trakcie') or (status.rns.status == 'W trakcie') or (status.jazz.status == 'W trakcie') %}
{% if is_running %}
<script>
setTimeout(function() { window.location.reload(); }, 5000);
</script>
{% endif %}
<div class="row">
{% if msg %}
<div class="col-12 mb-3">
<div class="alert alert-info alert-dismissible fade show m-0" role="alert">
<strong>Info:</strong> {{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
{% endif %}
<div class="col-12 col-xl-8 mb-4 mb-xl-0">
<div class="mb-4">
<div class="eyebrow mb-2">Panel operacyjny</div>
<h1 class="page-title">Status systemu</h1>
</div>
<div class="row mb-4">
{% for st, name in [('radio357', 'Radio 357'), ('rns', 'Radio Nowy Świat'), ('jazz', 'Radio Jazz')] %}
<div class="col-12 col-lg-4">
<div class="card station-card text-white mb-3 h-100">
<div class="card-header fw-bold">
{{ name }}
</div>
<div class="card-body">
<div class="mb-3">
<span class="metric-label d-block mb-1">Status</span>
{% if status[st].status == 'OK' %}<span class="badge bg-success">OK</span>
{% elif status[st].status == 'Błąd' %}<span class="badge bg-danger">Błąd</span>
{% elif status[st].status == 'W trakcie' %}
<span class="badge bg-primary">W trakcie <span class="spinner-border spinner-border-sm" style="width: 10px; height: 10px;" role="status" aria-hidden="true"></span></span>
{% if status[st].progress %}<br><small class="text-info mt-1 d-block">Zapytania: {{ status[st].progress }}</small>{% endif %}
{% else %}<span class="badge bg-warning text-dark">{{ status[st].status }}</span>{% endif %}
</div>
<div class="row g-3">
<div class="col-12"><span class="metric-label d-block">Ostatni sync</span><span class="metric-value">{% if status[st].last_run %}{{ status[st].last_run.strftime('%d.%m.%Y %H:%M') }}{% else %}Nigdy{% endif %}</span></div>
{% if st in ['radio357', 'rns'] %}
<div class="col-6"><span class="metric-label d-block">Programy</span><span class="metric-value">{{ stats['r357_progs' if st == 'radio357' else 'rns_progs'] }}</span></div>
<div class="col-6"><span class="metric-label d-block">Audio / odcinki</span><span class="metric-value">{{ stats['r357_audio_eps' if st == 'radio357' else 'rns_audio_eps'] }} / {{ stats['r357_eps' if st == 'radio357' else 'rns_eps'] }}</span></div>
{% endif %}
</div>
</div>
<div class="card-footer border-0 bg-transparent d-grid gap-2 pb-3">
{% if st != 'jazz' %}
<form method="POST" action="/test-auth" class="m-0">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input type="hidden" name="station" value="{{ st }}">
<button class="btn btn-sm btn-outline-info w-100" title="Sprawdź czy sesja jest aktywna">Sprawdź status</button>
</form>
<form method="POST" action="/force-relogin" class="m-0">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input type="hidden" name="station" value="{{ st }}">
<button class="btn btn-sm btn-outline-warning w-100" title="Wymuś nowe logowanie z API" onclick="return confirm('Czy na pewno chcesz wymusić nowe logowanie?')">Relogin</button>
</form>
{% endif %}
<form method="POST" action="/force-sync" class="m-0">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input type="hidden" name="station" value="{{ st }}">
<button class="btn btn-sm btn-light w-100" {% if status[st].status == 'W trakcie' %}disabled{% endif %}>
{% if status[st].status == 'W trakcie' %}
Pobieranie...
{% else %}
Sync
{% endif %}
</button>
</form>
{% if status[st].status == 'W trakcie' %}
<form method="POST" action="/stop-sync" class="m-0">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input type="hidden" name="station" value="{{ st }}">
<button class="btn btn-sm btn-outline-danger w-100" onclick="return confirm('Zatrzymać synchronizację?')">
⏹ Stop
</button>
</form>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
<div class="d-flex justify-content-between align-items-center mt-5 mb-3">
<h3 class="m-0">Błędy wymagające uwagi</h3>
<span class="badge {% if pending_errors %}bg-danger{% else %}bg-success{% endif %}">
{{ pending_errors|length }}
</span>
</div>
{% if pending_errors %}
<div class="list-group mb-4">
{% for error in pending_errors %}
<div class="list-group-item list-group-item-dark bg-secondary text-white {% if error.severity == 'warning' %}border-warning{% else %}border-danger{% endif %} mb-2 rounded">
<div class="d-flex justify-content-between gap-3">
<strong>{% if error.severity == 'warning' %}Ostrzeżenie{% else %}Błąd{% endif %}: {{ error.station }}</strong>
<small>{{ error.last_seen.strftime('%Y-%m-%d %H:%M') }}</small>
</div>
<div class="mt-1">{{ error.message }}</div>
<div class="d-flex justify-content-between align-items-center mt-2">
<small class="text-warning">Powtórzenia: {{ error.occurrences }}</small>
<form method="POST" action="/errors/{{ error.id }}/acknowledge" class="m-0">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button class="btn btn-sm btn-outline-light">Potwierdź odczytanie</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-success mb-4">Brak błędów wymagających odczytania.</div>
{% endif %}
{% if acknowledged_errors %}
<details class="mb-4">
<summary class="text-muted">Ostatnio odczytane błędy ({{ acknowledged_errors|length }})</summary>
<div class="list-group mt-2">
{% for error in acknowledged_errors %}
<div class="list-group-item list-group-item-dark bg-secondary text-white border-secondary mb-1">
<div class="d-flex justify-content-between gap-3">
<strong>{{ error.station }}</strong>
<small>{{ error.last_seen.strftime('%Y-%m-%d %H:%M') }}</small>
</div>
<div>{{ error.message }}</div>
<small class="text-muted">Powtórzenia: {{ error.occurrences }}</small>
</div>
{% endfor %}
</div>
</details>
{% endif %}
<h3 class="mt-5 mb-3">Ostatnie logi (z pamięci podręcznej)</h3>
<div class="card bg-dark text-light border-secondary">
<div class="card-body">
<div id="log-container" class="log-box" style="max-height: 400px; overflow-y: auto; font-family: monospace; font-size: 0.85rem;">
{% for line in status.logs %}
<div>{{ line }}</div>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card config-surface text-white h-100">
<div class="card-header border-secondary">Konfiguracja Systemu</div>
<div class="card-body p-4">
<form method="POST" action="/config">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<h5 class="mb-3 text-info">Radio 357</h5>
<div class="mb-2">
<input type="email" name="r357_email" class="form-control form-control-sm" placeholder="Email (logowanie)" value="{{ config.r357_email or '' }}">
</div>
<div class="mb-2">
<input type="password" name="r357_password" class="form-control form-control-sm" placeholder="Hasło (zostaw puste aby nie zmieniać)">
</div>
<div class="row g-2 mb-4">
<div class="col-4">
<label class="form-label small text-muted m-0">Sync co (godzin)</label>
<input type="number" name="r357_interval" class="form-control form-control-sm" value="{{ config.r357_interval or '6' }}">
</div>
<div class="col-4">
<label class="form-label small text-muted m-0">Max odcinków backfill</label>
<input type="number" name="r357_limit" class="form-control form-control-sm" value="{{ config.r357_limit or '600' }}">
</div>
<div class="col-4">
<label class="form-label small text-muted m-0">Hard limit (all)</label>
<input type="number" name="r357_hard_limit" class="form-control form-control-sm" value="{{ config.r357_hard_limit or '1000' }}">
</div>
</div>
<h5 class="mb-3 text-info">Radio Nowy Świat</h5>
<div class="mb-2">
<input type="email" name="rns_email" class="form-control form-control-sm" placeholder="Email (logowanie)" value="{{ config.rns_email or '' }}">
</div>
<div class="mb-2">
<input type="password" name="rns_password" class="form-control form-control-sm" placeholder="Hasło (zostaw puste aby nie zmieniać)">
</div>
<div class="row g-2 mb-4">
<div class="col-4">
<label class="form-label small text-muted m-0">Sync co (godzin)</label>
<input type="number" name="rns_interval" class="form-control form-control-sm" value="{{ config.rns_interval or '6' }}">
</div>
<div class="col-4">
<label class="form-label small text-muted m-0">Max stron backfill</label>
<input type="number" name="rns_limit" class="form-control form-control-sm" value="{{ config.rns_limit or '50' }}">
</div>
<div class="col-4">
<label class="form-label small text-muted m-0">Hard limit (all)</label>
<input type="number" name="rns_hard_limit" class="form-control form-control-sm" value="{{ config.rns_hard_limit or '500' }}">
</div>
</div>
<h5 class="mb-3 text-info">Radio Jazz</h5>
<div class="row g-2 mb-4">
<div class="col-12">
<label class="form-label small text-muted m-0">Sync co (godzin)</label>
<input type="number" name="jazz_interval" class="form-control form-control-sm" value="{{ config.jazz_interval or '24' }}">
</div>
</div>
<h5 class="mb-3 mt-4 text-info">Powiadomienia ntfy.sh</h5>
<div class="mb-2">
<input type="text" name="ntfy_url" class="form-control form-control-sm" placeholder="Adres serwera (np. https://ntfy.sh)" value="{{ config.ntfy_url or '' }}">
</div>
<div class="mb-2">
<input type="text" name="ntfy_topic" class="form-control form-control-sm" placeholder="Temat (Topic)" value="{{ config.ntfy_topic or '' }}">
</div>
<div class="row g-2 mb-4">
<div class="col-6">
<input type="text" name="ntfy_user" class="form-control form-control-sm" placeholder="Użytkownik (opcj.)" value="{{ config.ntfy_user or '' }}">
</div>
<div class="col-6">
<input type="password" name="ntfy_password" class="form-control form-control-sm" placeholder="Hasło (opcj.)">
</div>
</div>
<div class="d-grid mt-4">
<button type="submit" class="btn btn-primary">Zapisz konfigurację</button>
</div>
</form>
<form method="POST" action="/test-ntfy" class="mt-2">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="btn btn-outline-info btn-sm w-100">Testuj powiadomienie ntfy</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+67
View File
@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block content %}
<div class="page-toolbar d-flex justify-content-between align-items-center mt-2 gap-3">
<div><div class="eyebrow mb-2">Biblioteka</div><h1 class="page-title">Programy: {{ station }}</h1></div>
<a href="/feeds/{{ station }}.opml" class="btn btn-warning" target="_blank">OPML dla tej stacji</a>
</div>
{% if msg %}
<div class="alert alert-info alert-dismissible fade show" role="alert">
<strong>Info:</strong> {{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<div class="row row-cols-1 row-cols-md-2 g-3">
{% for prog in programs %}
<div class="col">
<div class="card program-card h-100 text-white">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
{% if prog.image %}
<img src="{{ prog.image }}" class="podcast-img me-3" alt="Okładka">
{% endif %}
<h5 class="card-title program-title m-0">{{ prog.name }}</h5>
</div>
<p class="card-text small text-muted text-truncate mb-3">{{ prog.description or 'Brak opisu programu.' }}</p>
<div class="mt-2 small">
<div><strong>Ostatni sync:</strong> {{ prog.last_catchup_str }}</div>
{% if station == 'rns' %}
<div><strong>Status backfill:</strong>
{% if prog.backfill_complete or (prog.total_pages > 0 and prog.backfill_page > prog.total_pages) %}
<span class="text-success">✅ Zakończony</span>
{% elif prog.backfill_page <= 2 %}
{% if prog.total_eps > 0 %}
<span class="text-info">Catch-up wykonany; od strony 2</span>
{% else %}
<span class="text-muted">Nie rozpoczęty</span>
{% endif %}
{% else %}
<span class="text-warning">Strona {{ prog.backfill_page - 1 }} z {{ prog.total_pages if prog.total_pages > 0 else '?' }}</span>
{% endif %}
</div>
{% endif %}
{% if station != 'jazz' %}
<div><strong>Odcinki:</strong> {{ prog.total_eps }} (brak audio: {% if prog.missing_urls_count > 0 %}<span class="text-danger fw-bold">{{ prog.missing_urls_count }}</span>{% else %}0{% endif %})</div>
{% endif %}
</div>
</div>
<div class="card-footer d-flex justify-content-between bg-dark">
{% if station != 'jazz' %}
<a href="/episodes/{{ station }}/{{ prog.slug }}" class="btn btn-outline-light btn-sm">Odcinki</a>
<form method="POST" action="/force-sync-program" style="margin:0;">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input type="hidden" name="station" value="{{ station }}">
<input type="hidden" name="slug" value="{{ prog.slug }}">
<button class="btn btn-outline-info btn-sm" onclick="this.disabled=true; this.form.submit();">Wymuś Sync</button>
</form>
{% else %}
<a href="https://podkasty.radiojazz.fm/@{{ prog.slug }}/feed.xml" target="_blank" class="btn btn-outline-light btn-sm">Oryginalny RSS</a>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}