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
+6
View File
@@ -0,0 +1,6 @@
# Generate a new value for each deployment.
RADIOSYNC_SECRET_KEY=
# Optional: Fernet key for encrypting values stored in SQLite.
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
RADIOSYNC_CONFIG_KEY=
+34
View File
@@ -0,0 +1,34 @@
# Secrets and local configuration
.env
.env.*
!.env.example
# Runtime data
data/*
!data/.gitkeep
*.db
*.sqlite
*.sqlite3
# Python cache and local environments
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
env/
# Test and tooling artifacts
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
# IDE and OS files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea/
.DS_Store
Thumbs.db
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y tzdata && \
ln -snf /usr/share/zoneinfo/Europe/Warsaw /etc/localtime && echo Europe/Warsaw > /etc/timezone && \
apt-get clean
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+110
View File
@@ -0,0 +1,110 @@
from sqlalchemy import create_engine, Column, String, Integer, Boolean, Float, Text, DateTime, TypeDecorator
from sqlalchemy.orm import declarative_base, sessionmaker
from .security import encrypt_config_value, decrypt_config_value
DATABASE_URL = "sqlite:///./data/radiosync.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
class EncryptedString(TypeDecorator):
impl = String
cache_ok = True
def process_bind_param(self, value, dialect):
return encrypt_config_value(value)
def process_result_value(self, value, dialect):
return decrypt_config_value(value)
class Config(Base):
__tablename__ = "config"
key = Column(String, primary_key=True, index=True)
value = Column(EncryptedString)
class Program(Base):
__tablename__ = "programs"
id = Column(Integer, primary_key=True, index=True)
station = Column(String, index=True) # "radio357", "rns", "jazz"
slug = Column(String, index=True)
name = Column(String)
description = Column(Text, nullable=True)
image = Column(String, nullable=True)
# Sync tracking
backfill_page = Column(Integer, default=1)
backfill_complete = Column(Boolean, default=False)
total_pages = Column(Integer, default=0)
last_catchup = Column(Float, default=0.0)
next_catchup_after = Column(Float, default=0.0) # timestamp – nie sprawdzaj przed tym czasem
class Episode(Base):
__tablename__ = "episodes"
id = Column(Integer, primary_key=True, index=True)
station = Column(String, index=True)
program_slug = Column(String, index=True)
ep_id = Column(String, index=True) # ID from the radio API
title = Column(String)
authors = Column(String, nullable=True)
url = Column(String, nullable=True)
image = Column(String, nullable=True)
pub_date = Column(String, nullable=True) # ISO format or YYYY-MM-DD
duration_secs = Column(Integer, default=0)
description = Column(Text, nullable=True)
is_broken = Column(Boolean, default=False)
class ErrorEvent(Base):
__tablename__ = "error_events"
id = Column(Integer, primary_key=True, index=True)
station = Column(String, index=True, nullable=False)
source = Column(String, nullable=False)
message = Column(Text, nullable=False)
severity = Column(String, default="error", nullable=False)
fingerprint = Column(String, index=True, nullable=False)
occurrences = Column(Integer, default=1, nullable=False)
first_seen = Column(DateTime, nullable=False)
last_seen = Column(DateTime, nullable=False)
acknowledged = Column(Boolean, default=False, nullable=False)
acknowledged_at = Column(DateTime, nullable=True)
snapshot_path = Column(String, nullable=True)
def init_db():
Base.metadata.create_all(bind=engine)
import sqlite3, os
db_path = DATABASE_URL.replace("sqlite:///", "")
if os.path.exists(db_path):
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
existing_columns = {
row[1] for row in cursor.execute("PRAGMA table_info(programs)")
}
for col, typedef in [
("next_catchup_after", "REAL DEFAULT 0.0"),
("total_pages", "INTEGER DEFAULT 0"),
]:
if col not in existing_columns:
cursor.execute(f"ALTER TABLE programs ADD COLUMN {col} {typedef}")
error_columns = {row[1] for row in cursor.execute("PRAGMA table_info(error_events)")}
if "snapshot_path" not in error_columns:
cursor.execute("ALTER TABLE error_events ADD COLUMN snapshot_path TEXT")
# Encrypt legacy plaintext values using the raw SQLite connection;
# assigning the same decrypted value through the ORM is not dirty.
rows = cursor.execute("SELECT key, value FROM config").fetchall()
for key, value in rows:
if value is not None and not value.startswith("enc:v1:"):
cursor.execute("UPDATE config SET value = ? WHERE key = ?",
(encrypt_config_value(value), key))
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+48
View File
@@ -0,0 +1,48 @@
import json
import os
import re
import hashlib
from datetime import datetime
class ScraperError(Exception):
def __init__(self, station, code, message, url=None, status_code=None):
self.station = station
self.code = code
self.url = url
self.status_code = status_code
details = f"[{code}] {message}"
if status_code is not None:
details += f" (HTTP {status_code})"
if url:
details += f" URL: {url}"
super().__init__(details)
def save_response_snapshot(station, code, url, content, status_code=None):
directory = os.path.join("data", "diagnostics")
try:
os.makedirs(directory, exist_ok=True)
digest = hashlib.sha256(f"{station}:{code}:{url}".encode("utf-8")).hexdigest()[:16]
safe_station = re.sub(r"[^a-zA-Z0-9_-]", "_", station)
base_path = os.path.join(directory, f"{safe_station}-{code}-{digest}")
with open(f"{base_path}.json", "w", encoding="utf-8") as metadata_file:
json.dump({"station": station, "code": code, "url": url, "status_code": status_code,
"updated_at": datetime.now().isoformat()}, metadata_file, ensure_ascii=False, indent=2)
with open(f"{base_path}.html", "w", encoding="utf-8") as response_file:
response_file.write((content or "")[:2_000_000])
return base_path
except OSError as exc:
return f"snapshot unavailable: {exc}"
def invalid_response(station, code, message, url, response=None, content=None):
snapshot = save_response_snapshot(
station,
url,
response.text if response is not None else content,
response.status_code if response is not None else None,
)
raise ScraperError(station, code, f"{message}. Snapshot: {snapshot}", url,
response.status_code if response is not None else None)
+45
View File
@@ -0,0 +1,45 @@
import ipaddress
import socket
from urllib.parse import urlparse
import requests
def validate_notification_url(url: str):
parsed = urlparse(url or "")
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("Adres ntfy musi być adresem HTTP(S) bez danych logowania")
try:
addresses = {info[4][0] for info in socket.getaddrinfo(parsed.hostname, parsed.port, type=socket.SOCK_STREAM)}
if any(ipaddress.ip_address(address).is_private or ipaddress.ip_address(address).is_loopback or
ipaddress.ip_address(address).is_link_local for address in addresses):
raise ValueError("Adres ntfy wskazuje na prywatną lub lokalną sieć")
except socket.gaierror as exc:
raise ValueError("Nie można rozpoznać hosta ntfy") from exc
def send_notification(url: str, topic: str, title: str, message: str, username: str = None, password: str = None):
if not url or not topic:
return False
validate_notification_url(url)
full_url = f"{url.rstrip('/')}/{topic}"
headers = {
"Title": title.encode('utf-8')
}
auth = None
if username and password:
auth = (username, password)
try:
response = requests.post(
full_url,
data=message.encode('utf-8'),
headers=headers,
auth=auth,
timeout=5
)
response.raise_for_status()
return True
except Exception as e:
print(f"Failed to send ntfy notification: {e}")
return False
+111
View File
@@ -0,0 +1,111 @@
from sqlalchemy.orm import Session
from xml.etree import ElementTree as ET
from xml.sax.saxutils import escape
from email.utils import format_datetime
from datetime import datetime
from .database import Program, Episode
def get_base_url(request):
# Retrieve base URL for constructing absolute paths (from FastAPI Request)
return str(request.base_url).rstrip('/')
def generate_master_opml(db: Session, base_url: str, station: str = None):
opml_outlines = []
# Radio 357
r357 = db.query(Program).filter_by(station="radio357").order_by(Program.name).all() if not station or station == "radio357" else []
if r357:
opml_outlines.append(' <outline text="Radio 357">')
for prog in r357:
title = escape(prog.name, {'"': "&quot;", "'": "&apos;"})
xmlUrl = f"{base_url}/feeds/radio357/{prog.slug}.xml"
opml_outlines.append(f' <outline text="{title}" type="rss" xmlUrl="{xmlUrl}" />')
opml_outlines.append(' </outline>')
# RNŚ
rns = db.query(Program).filter_by(station="rns").order_by(Program.name).all() if not station or station == "rns" else []
if rns:
opml_outlines.append(' <outline text="Radio Nowy Świat">')
for prog in rns:
title = escape(prog.name, {'"': "&quot;", "'": "&apos;"})
xmlUrl = f"{base_url}/feeds/rns/{prog.slug}.xml"
opml_outlines.append(f' <outline text="{title}" type="rss" xmlUrl="{xmlUrl}" />')
opml_outlines.append(' </outline>')
# Radio Jazz FM (Direct to source feeds!)
jazz = db.query(Program).filter_by(station="jazz").order_by(Program.name).all() if not station or station == "jazz" else []
if jazz:
opml_outlines.append(' <outline text="Radio Jazz FM">')
for prog in jazz:
title = escape(prog.name, {'"': "&quot;", "'": "&apos;"})
# Link directly to their server!
xmlUrl = f"https://podkasty.radiojazz.fm/@{prog.slug}/feed.xml"
opml_outlines.append(f' <outline text="{title}" type="rss" xmlUrl="{xmlUrl}" />')
opml_outlines.append(' </outline>')
xml_content = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml_content += '<opml version="1.0">\n <body>\n'
xml_content += '\n'.join(opml_outlines) + '\n'
xml_content += ' </body>\n</opml>'
return xml_content
def generate_podcast_rss(db: Session, station: str, slug: str):
prog = db.query(Program).filter_by(station=station, slug=slug).first()
if not prog:
return None
eps = db.query(Episode).filter_by(station=station, program_slug=slug, is_broken=False).filter(Episode.url.isnot(None)).order_by(Episode.pub_date.desc()).all()
rss = ET.Element("rss", {"version": "2.0", "xmlns:itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd"})
channel = ET.SubElement(rss, "channel")
ET.SubElement(channel, "title").text = prog.name
ET.SubElement(channel, "description").text = prog.description or ""
if prog.image:
ET.SubElement(channel, "itunes:image", {"href": prog.image})
image_el = ET.SubElement(channel, "image")
ET.SubElement(image_el, "url").text = prog.image
ET.SubElement(image_el, "title").text = prog.name
for ep in eps:
item = ET.SubElement(channel, "item")
date_prefix = f"({ep.pub_date[:10]}) " if ep.pub_date else ""
ET.SubElement(item, "title").text = f"{date_prefix}{ep.title}"
ET.SubElement(item, "itunes:author").text = ep.authors or ""
if ep.description:
ET.SubElement(item, "description").text = ep.description
ET.SubElement(item, "itunes:summary").text = ep.description
if ep.pub_date:
try:
# radio357 format: 2024-03-22T08:00:00+00:00 or rns: 2024-03-22
if 'T' in ep.pub_date:
dt = datetime.fromisoformat(ep.pub_date)
else:
dt = datetime.strptime(ep.pub_date, "%Y-%m-%d")
ET.SubElement(item, "pubDate").text = format_datetime(dt)
except:
ET.SubElement(item, "pubDate").text = ep.pub_date
if ep.duration_secs:
ET.SubElement(item, "itunes:duration").text = str(ep.duration_secs)
ET.SubElement(item, "guid", {"isPermaLink": "false"}).text = f"{station}-{ep.ep_id}"
ET.SubElement(item, "enclosure", {"url": ep.url, "type": "audio/mpeg", "length": "0"})
if ep.image or prog.image:
ET.SubElement(item, "itunes:image", {"href": ep.image or prog.image})
tree = ET.ElementTree(rss)
try:
ET.indent(tree, space=" ", level=0)
except AttributeError:
pass # older python versions
from io import BytesIO
f = BytesIO()
tree.write(f, encoding="utf-8", xml_declaration=True)
return f.getvalue()
+57
View File
@@ -0,0 +1,57 @@
import requests
from bs4 import BeautifulSoup
from sqlalchemy.orm import Session
from ..database import Program
BASE_URL = "https://podkasty.radiojazz.fm"
class JazzScraper:
def __init__(self, db: Session, logger=print):
self.db = db
self.logger = logger
def sync_programs(self):
self.logger("JAZZ: Pobieranie audycji...")
try:
r = requests.get(BASE_URL, headers={"User-Agent": "Mozilla/5.0"}, timeout=15)
r.raise_for_status()
html = r.text
except Exception as e:
self.logger(f"JAZZ: Błąd pobierania bazy: {e}")
return
soup = BeautifulSoup(html, 'html.parser')
links = soup.find_all('a', href=True)
found = 0
for link in links:
href = link['href']
if '/@' in href:
slug = href.split('/@')[-1].split('/')[0]
if not slug: continue
text = link.get_text(strip=True)
if not text or text.startswith('@') or "Recent activity" in text:
continue
clean_title = text
if clean_title.endswith(f"@{slug}"):
clean_title = clean_title[:-len(f"@{slug}")].strip()
if clean_title.startswith("Ż "):
clean_title = clean_title[2:].strip()
elif clean_title.startswith("Ż"):
clean_title = clean_title[1:].strip()
prog = self.db.query(Program).filter_by(station="jazz", slug=slug).first()
if not prog:
self.db.add(Program(
station="jazz", slug=slug, name=clean_title,
description="Radio Jazz FM", image=""
))
found += 1
self.db.commit()
self.logger(f"JAZZ: Znaleziono {found} nowych audycji (łącznie zaktualizowano).")
def run_full_sync(self):
self.sync_programs()
+282
View File
@@ -0,0 +1,282 @@
import requests
import json
import base64
import time
import os
from sqlalchemy.orm import Session
from ..database import Program, Episode, Config
from ..diagnostics import ScraperError, invalid_response
def decode_jwt_exp(token):
try:
parts = token.split('.')
if len(parts) != 3: return 0
payload_b64 = parts[1]
payload_b64 += "=" * ((4 - len(payload_b64) % 4) % 4)
return json.loads(base64.b64decode(payload_b64).decode('utf-8')).get("exp", 0)
except:
return 0
def get_audio_length(url):
try:
r = requests.head(url, timeout=5)
return int(r.headers.get('Content-Length', 0))
except:
return 0
class Radio357Scraper:
def __init__(self, db: Session, logger=print, progress_callback=None, stop_flag=None):
self.db = db
self.logger = logger
self.progress_callback = progress_callback
self.stop_flag = stop_flag
self.requests_made = 0
limit_conf = self.db.query(Config).filter_by(key="r357_limit").first()
self.backfill_limit = int(limit_conf.value) if limit_conf and limit_conf.value.isdigit() else 600
hard_limit_conf = self.db.query(Config).filter_by(key="r357_hard_limit").first()
self.hard_limit = int(hard_limit_conf.value) if hard_limit_conf and hard_limit_conf.value.isdigit() else 1000
self.start_time = time.time()
self.max_execution_time = 900 # 15 minut max na cały sync
if self.progress_callback: self.progress_callback(self.requests_made, self.hard_limit)
def check_timeout(self):
if self.stop_flag and self.stop_flag():
raise Exception("Zatrzymano na żądanie użytkownika.")
if time.time() - self.start_time > self.max_execution_time:
raise Exception(f"Przekroczono limit czasu wykonywania skryptu ({self.max_execution_time // 60} min). Zatrzymano awaryjnie.")
def _get(self, url, headers=None):
self.check_timeout()
if self.requests_made >= self.hard_limit:
self.logger(f"R357: Limit {self.hard_limit} zapytań osiągnięty. Przerywam.")
return None
self.requests_made += 1
if self.progress_callback: self.progress_callback(self.requests_made, self.hard_limit)
if not headers:
headers = {}
headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
try:
r = requests.get(url, headers=headers, timeout=10)
if r.status_code == 200:
try:
data = r.json()
except ValueError:
invalid_response("radio357", "INVALID_JSON", "API zwróciło nieprawidłowy JSON", url, r)
embedded = data.get("_embedded") if isinstance(data, dict) else None
if not isinstance(embedded, dict) or not isinstance(embedded.get("podcasts"), list):
invalid_response("radio357", "API_SCHEMA_CHANGED", "Brak oczekiwanej listy podcastów w odpowiedzi API", url, r)
return data
else:
invalid_response("radio357", "HTTP_ERROR", "Błąd pobierania danych API", url, r)
except Exception as e:
if isinstance(e, ScraperError):
raise
raise ScraperError("radio357", "NETWORK_ERROR", f"Błąd zapytania: {e}", url) from e
return None
def get_token(self):
token_conf = self.db.query(Config).filter_by(key="r357_token").first()
if token_conf and decode_jwt_exp(token_conf.value) > time.time() + 3600:
return token_conf.value
email = self.db.query(Config).filter_by(key="r357_email").first()
password = self.db.query(Config).filter_by(key="r357_password").first()
if not email or not password:
self.logger("R357: Brak danych logowania w bazie.")
return None
self.logger("R357: Pobieranie nowego tokenu...")
url = "https://auth.r357.eu/api/auth/login"
payload = {"email": email.value, "password": password.value}
headers = {
"Content-Type": "application/json",
"Origin": "https://konto.radio357.pl",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
}
try:
r = requests.post(url, json=payload, headers=headers, timeout=10)
if r.status_code == 200:
new_token = r.json().get("accessToken")
if new_token:
if not token_conf:
token_conf = Config(key="r357_token", value=new_token)
self.db.add(token_conf)
else:
token_conf.value = new_token
self.db.commit()
return new_token
else:
self.logger(f"R357 Błąd logowania: {r.status_code} {r.text}")
except Exception as e:
self.logger(f"R357 Wyjątek logowania: {e}")
return None
def check_auth_status(self):
token_conf = self.db.query(Config).filter_by(key="r357_token").first()
if not token_conf:
return False, "Brak zapisanego tokenu."
exp = decode_jwt_exp(token_conf.value)
if exp < time.time():
return False, "Token wygasł."
return True, "Token aktywny."
def sync_episodes(self, specific_program_slug=None):
self.logger("R357: Rozpoczynam synchronizację...")
page = 0
new_found = 0
is_first_run = self.db.query(Episode).filter_by(station="radio357").count() == 0
# Przy pierwszym uruchomieniu indeks metadanych musi przejść przez całe API.
# W kolejnych uruchomieniach zostawiamy budżet na backfill URL-i audio.
catchup_limit = self.hard_limit if is_first_run else max(1, self.hard_limit - self.backfill_limit)
seen_slugs = set() # Guard przeciw duplikatom w tej sesji
while True:
if self.requests_made >= catchup_limit:
self.logger(f"R357: [{self.requests_made}/{self.hard_limit}] Limit Catchup osiągnięty. Zostawiam resztę dla Backfill.")
break
url = f"https://static.radio357.pl/api/content/v1/podcasts?page={page}"
self.logger(f"R357: [{self.requests_made}/{self.hard_limit}] Pobieram stronę {page}...")
data = self._get(url)
if not data:
raise Exception(f"Błąd sieci/API podczas pobierania strony {page} (Radio 357)")
episodes = data.get("_embedded", {}).get("podcasts", [])
if not episodes:
break
all_known = True
page_has_target = False
for ep in episodes:
ep_id = str(ep["id"])
# Extract program info
programs_list = ep.get("programs")
if not programs_list: continue
program = programs_list[0]
slug = program.get("slug", f"program_{program['id']}")
if specific_program_slug and slug != specific_program_slug:
continue # Skip if we are targeting a specific program
page_has_target = True
existing = self.db.query(Episode).filter_by(station="radio357", ep_id=ep_id).first()
if not existing:
all_known = False
# Ensure program exists (cache guard against duplicates)
if slug not in seen_slugs:
db_prog = self.db.query(Program).filter_by(station="radio357", slug=slug).first()
if not db_prog:
db_prog = Program(
station="radio357",
slug=slug,
name=program.get("name", f"Podcast {program['id']}"),
image=program.get("image", ""),
description=program.get("description", "")
)
self.db.add(db_prog)
self.db.flush() # Natychmiastowy flush żeby kolejne odcinki widziały program
seen_slugs.add(slug)
team = ep.get("team", [])
authors = ", ".join([t.get("name", "") for t in team]) or "Radio 357"
new_ep = Episode(
station="radio357",
program_slug=slug,
ep_id=ep_id,
title=ep.get("subTitle") or ep.get("title", ""),
duration_secs=ep.get("duration", 0),
image=ep.get("image", ""),
description=ep.get("description", ""),
pub_date=ep.get("published", ""),
authors=authors,
url=None,
is_broken=False
)
self.db.add(new_ep)
new_found += 1
self.db.commit()
if not is_first_run and all_known and not specific_program_slug:
self.logger(f"R357: Wszystkie odcinki na stronie {page} są znane. Zakończono catchup.")
break
if specific_program_slug and page_has_target and all_known:
self.logger(f"R357: Wszystkie odcinki programu {specific_program_slug} na stronie {page} są znane. Zakończono catchup programu.")
break
if len(episodes) < data.get("page_size", 250):
break
page += 1
self.logger(f"R357: Znaleziono {new_found} nowych odcinków w bazie.")
def backfill_urls(self, specific_program_slug=None):
backfill_count = 0
self.logger(f"R357: Rozpoczynam pobieranie linków audio (limit {self.backfill_limit})...")
query = self.db.query(Episode).filter_by(station="radio357", url=None, is_broken=False).order_by(Episode.pub_date.desc())
if specific_program_slug:
query = query.filter_by(program_slug=specific_program_slug)
missing_eps = query.all()
token = self.get_token()
if not token:
self.logger("R357: Brak tokena, przerywam backfill url")
return
fetched = 0
for ep in missing_eps:
self.check_timeout()
if self.requests_made >= self.hard_limit or backfill_count >= self.backfill_limit:
break
self.logger(f"R357: Pobieram URL dla {ep.title[:30]}...")
gateway_url = f"https://gateway.r357.eu/api/content/podcast/{ep.ep_id}/url"
self.requests_made += 1
if self.progress_callback: self.progress_callback(self.requests_made, self.hard_limit)
backfill_count += 1
try:
r = requests.get(gateway_url, headers={
"Authorization": f"Bearer {token}",
"Origin": "https://radio357.pl",
"User-Agent": "Mozilla/5.0"
}, timeout=10)
if r.status_code == 200:
audio_url = r.json().get("url")
if audio_url:
ep.url = audio_url
fetched += 1
elif r.status_code == 401 or r.status_code == 403:
self.logger("R357: Token odrzucony przez gateway. Wymagam reloginu.")
# Usuwamy token, zeby przy nastepnym runie pobral nowy
self.db.query(Config).filter_by(key="r357_token").delete()
self.db.commit()
raise Exception("R357 Token Expired")
else:
self.logger(f"R357 Błąd HTTP {r.status_code} podczas pobierania audio: {r.text[:100]}")
except Exception as e:
self.logger(f"R357: Błąd URL dla odcinka {ep.ep_id}: {e}")
if "Token Expired" in str(e):
raise
time.sleep(0.5)
self.db.commit()
self.logger(f"R357: Pomyślnie pobrano {fetched} nowych linków.")
def run_full_sync(self, specific_program_slug=None):
self.sync_episodes(specific_program_slug)
self.backfill_urls(specific_program_slug)
self.logger(f"R357: Koniec. Wysłano zapytania: {self.requests_made}/{self.hard_limit} (w tym backfill ograniczony do {self.backfill_limit}).")
+508
View File
@@ -0,0 +1,508 @@
import requests
import time
import os
import re
import urllib.parse
from bs4 import BeautifulSoup
from sqlalchemy.orm import Session
from ..database import Program, Episode, Config
from ..diagnostics import ScraperError, invalid_response
MAX_REQUESTS_LIMIT = 50
BASE_URL = "https://nowyswiat.online"
def get_cookie_for_url(cookie_jar, name, url):
"""Wybiera najbardziej szczegółowe cookie, gdy jar zawiera duplikaty nazwy."""
parsed_url = urllib.parse.urlparse(url)
host = parsed_url.hostname.lower()
path = parsed_url.path or "/"
candidates = []
for cookie in cookie_jar:
if cookie.name != name or (cookie.secure and parsed_url.scheme != "https"):
continue
domain = cookie.domain.lstrip(".").lower()
if domain and not (host == domain or host.endswith(f".{domain}")):
continue
cookie_path = cookie.path or "/"
if not path.startswith(cookie_path.rstrip("/") or "/"):
continue
candidates.append(cookie)
if not candidates:
return None
selected = max(candidates, key=lambda cookie: (len(cookie.path or "/"), len(cookie.domain or "")))
for cookie in candidates:
if cookie is not selected:
cookie_jar.clear(cookie.domain, cookie.path, cookie.name)
return selected.value
def parse_polish_date(date_str):
months = {
"stycznia": "01", "lutego": "02", "marca": "03", "kwietnia": "04",
"maja": "05", "czerwca": "06", "lipca": "07", "sierpnia": "08",
"września": "09", "października": "10", "listopada": "11", "grudnia": "12",
"styczeń": "01", "luty": "02", "marzec": "03", "kwiecień": "04",
"maj": "05", "czerwiec": "06", "lipiec": "07", "sierpień": "08",
"wrzesień": "09", "październik": "10", "listopad": "11", "grudzień": "12"
}
parts = date_str.lower().split()
if len(parts) == 3:
day = parts[0].zfill(2)
month = months.get(parts[1], "01")
year = parts[2]
return f"{year}-{month}-{day}"
return date_str
class RNScraper:
def __init__(self, db: Session, logger=print, progress_callback=None, warning_callback=None, stop_flag=None):
self.db = db
self.logger = logger
self.progress_callback = progress_callback
self.warning_callback = warning_callback
self.stop_flag = stop_flag
self.requests_made = 0
self.start_time = time.time()
self.max_execution_time = 900 # 15 minut max na cały sync
limit_conf = self.db.query(Config).filter_by(key="rns_limit").first()
self.backfill_limit = int(limit_conf.value) if limit_conf and limit_conf.value.isdigit() else 50
hard_limit_conf = self.db.query(Config).filter_by(key="rns_hard_limit").first()
self.hard_limit = int(hard_limit_conf.value) if hard_limit_conf and hard_limit_conf.value.isdigit() else 500
self.session = requests.Session()
self.session.headers.update({"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"})
if self.progress_callback: self.progress_callback(self.requests_made, self.hard_limit)
def check_timeout(self):
if self.stop_flag and self.stop_flag():
raise Exception("Zatrzymano na żądanie użytkownika.")
if time.time() - self.start_time > self.max_execution_time:
raise Exception(f"Przekroczono limit czasu wykonywania skryptu ({self.max_execution_time // 60} min). Zatrzymano awaryjnie.")
def _get_html(self, url):
self.check_timeout()
if self.requests_made >= self.hard_limit:
return None
for attempt in range(1, 4):
if self.requests_made >= self.hard_limit:
return None
self.requests_made += 1
if self.progress_callback: self.progress_callback(self.requests_made, self.hard_limit)
try:
r = self.session.get(url, timeout=15)
if r.status_code == 200:
return r.text
invalid_response("rns", "HTTP_ERROR", "Błąd pobierania strony", url, r)
except requests.exceptions.Timeout as e:
if attempt == 3:
raise ScraperError("rns", "NETWORK_TIMEOUT", "Trzy próby pobrania strony zakończyły się timeoutem", url) from e
self.logger(f"RNŚ: Timeout dla {url}, próba {attempt}/3. Ponawiam za 30 sekund.")
self.check_timeout()
time.sleep(30)
except Exception as e:
if isinstance(e, ScraperError):
raise
raise ScraperError("rns", "NETWORK_ERROR", f"Błąd zapytania: {e}", url) from e
return None
def perform_login(self):
email = self.db.query(Config).filter_by(key="rns_email").first()
password = self.db.query(Config).filter_by(key="rns_password").first()
if not email or not password:
self.logger("RNŚ: Brak danych logowania w bazie.")
return False
self.logger("RNŚ: Inicjalizacja logowania (pobieranie CSRF)...")
try:
r1 = self.session.get("https://nowyswiat.online/konto/zaloguj", timeout=15)
except Exception as e:
self.logger(f"RNŚ: Sieć zablokowała pobieranie CSRF: {e}")
return False
csrf_token = get_cookie_for_url(
self.session.cookies,
"csrf_cookie_neocms",
"https://nowyswiat.online/konto/zaloguj",
)
if not csrf_token:
self.logger("RNŚ: Nie udało się pobrać tokenu CSRF.")
return False
self.logger("RNŚ: Wysyłanie formularza...")
payload = {"csrf_neocms": csrf_token, "login": email.value, "password": password.value, "ufd_data": "{}"}
try:
r2 = self.session.post("https://nowyswiat.online/konto/zaloguj", data=payload, headers={
"X-Requested-With": "XMLHttpRequest"
}, timeout=15)
except Exception as e:
self.logger(f"RNŚ: Błąd sieci przy wysyłaniu formularza: {e}")
return False
# Check if login succeeded by looking for a session cookie or a success response
if '"status":"OK"' in r2.text or "logowanie udane" in r2.text.lower():
# Save cookies to DB (serialize)
cookies_dict = requests.utils.dict_from_cookiejar(self.session.cookies)
import json
cookie_conf = self.db.query(Config).filter_by(key="rns_cookies").first()
if not cookie_conf:
self.db.add(Config(key="rns_cookies", value=json.dumps(cookies_dict)))
else:
cookie_conf.value = json.dumps(cookies_dict)
self.db.commit()
return True
else:
self.logger(f"RNŚ: Błędne dane logowania (lub zmiana mechanizmu). Odpowiedź: {r2.text[:100]}")
return False
def ensure_auth(self):
cookie_conf = self.db.query(Config).filter_by(key="rns_cookies").first()
if cookie_conf:
import json
try:
cookies_dict = json.loads(cookie_conf.value)
self.session.cookies = requests.utils.cookiejar_from_dict(cookies_dict)
html = self._get_html(f"{BASE_URL}/")
if html and "wyloguj" in html.lower():
return True
self.logger("RNŚ: Zapisana sesja wygasła, wykonuję ponowne logowanie.")
except:
pass
return self.perform_login()
def check_auth_status(self):
cookie_conf = self.db.query(Config).filter_by(key="rns_cookies").first()
if not cookie_conf:
return False, "Brak zapisanych ciasteczek."
import json
try:
self.session.cookies = requests.utils.cookiejar_from_dict(json.loads(cookie_conf.value))
html = self._get_html("https://nowyswiat.online/")
if html and "wyloguj" in html.lower():
return True, "Ciasteczka aktywne, sesja poprawna."
return False, "Ciasteczka nieaktywne lub wygasły (brak dostępu do profilu)."
except Exception as e:
return False, f"Błąd: {e}"
def update_programs(self):
self.logger("RNŚ: Aktualizacja listy programów...")
html = self._get_html("https://nowyswiat.online/podcasty")
if not html: return
soup = BeautifulSoup(html, "html.parser")
links = soup.find_all("a", href=True)
program_links = [link for link in links if "rbroadcast=" in link["href"]]
if not program_links:
invalid_response("rns", "HTML_SCHEMA_CHANGED", "Nie znaleziono programów w stronie podcastów", "https://nowyswiat.online/podcasty", content=html)
for link in program_links:
href = link["href"]
if "rbroadcast=" in href:
parsed = urllib.parse.urlparse(href)
slug = urllib.parse.parse_qs(parsed.query).get("rbroadcast", [None])[0]
if not slug: continue
title_el = link.find("h2", class_="rns-search-dropdown-title")
title = title_el.text.strip() if title_el else slug
img_el = link.find("img")
img_url = img_el["src"] if img_el and img_el.has_attr("src") else ""
if img_url and not img_url.startswith("http"): img_url = f"{BASE_URL}/{img_url.lstrip('/')}"
prog = self.db.query(Program).filter_by(station="rns", slug=slug).first()
if not prog:
self.db.add(Program(
station="rns", slug=slug, name=title, image=img_url,
description=f"Radio Nowy Świat: {title}", backfill_page=2, backfill_complete=False
))
self.db.commit()
def _verify_audio_teaser(self, ep: Episode):
"""Wykrywa 1-minutowy teaser (rozmiar mniejszy niż ~2MB, mimo że audycja trwa > 5 min)."""
self.check_timeout()
if not ep.url: return False
if self.requests_made >= self.hard_limit: return False
self.requests_made += 1
if self.progress_callback: self.progress_callback(self.requests_made, self.hard_limit)
try:
r = self.session.head(ep.url, timeout=5)
cl = int(r.headers.get("Content-Length", 0))
if cl > 0 and cl < 2_500_000 and ep.duration_secs > 300:
self.logger(f"RNŚ: Wykryto uszkodzony link (Teaser 1-min) dla {ep.title}. Usuwam URL.")
ep.url = None
ep.is_broken = True
return True
except Exception as e:
pass
return False
def fetch_program_page(self, program_slug, page):
url = f"https://nowyswiat.online/podcasty?rbroadcast={program_slug}&page={page}"
html = self._get_html(url)
if not html:
raise Exception(f"Błąd sieci podczas pobierania strony {page} audycji {program_slug}")
soup = BeautifulSoup(html, "html.parser")
cards = soup.find_all("a", class_="rns-grid-podcast-card")
known_episode_count = self.db.query(Episode).filter_by(
station="rns", program_slug=program_slug
).count()
page_has_podcast_content = bool(soup.find(string=re.compile(r"podcast|podkast", re.IGNORECASE)))
if not cards and page > 1:
return 0, 0, page - 1
if not cards and (known_episode_count or not page_has_podcast_content):
invalid_response("rns", "HTML_SCHEMA_CHANGED", "Nie znaleziono kart odcinków dla istniejącego programu", url, content=html)
new_found = 0
missing_player_count = 0
valid_cards = 0
for card in cards:
href = card.get("href", "")
if not href or "/podcasty/" not in href: continue
valid_cards += 1
ep_id = href.split("/podcasty/")[-1].split("?")[0]
player_box = card.find("div", class_="rns-play-btn-box")
audio_url = player_box.get("data-neo-player-src") if player_box else None
if not player_box:
missing_player_count += 1
if audio_url and not audio_url.startswith("http"):
audio_url = f"{BASE_URL}/{audio_url.lstrip('/')}"
title_el = card.find("p", class_="rns-post-title")
raw_title = player_box.get("data-neo-player-title", "") if player_box else (title_el.text.strip() if title_el else ep_id)
title = BeautifulSoup(raw_title, "html.parser").text.strip() if raw_title else ep_id
raw_subtitle = player_box.get("data-neo-player-subtitle", "") if player_box else ""
authors = BeautifulSoup(raw_subtitle, "html.parser").text.strip() if raw_subtitle else "Radio Nowy Świat"
date_el = card.find("p", class_="rns-podcast-details-date")
pub_date = parse_polish_date(date_el.text.strip()) if date_el else ""
time_el = card.find("p", class_="rns-podcast-details-long")
duration_str = time_el.text.strip() if time_el else "0:00"
duration_secs = 0
if ":" in duration_str:
parts = duration_str.split(":")
if len(parts) == 3: duration_secs = int(parts[0])*3600 + int(parts[1])*60 + int(parts[2])
elif len(parts) == 2: duration_secs = int(parts[0])*60 + int(parts[1])
img_url = player_box.get("data-neo-player-img", "") if player_box else ""
if img_url and not img_url.startswith("http"): img_url = f"{BASE_URL}/{img_url.lstrip('/')}"
desc_el = card.find("p", class_="rns-post-card-desc")
description = desc_el.get_text(separator="\n").strip() if desc_el else ""
ep = self.db.query(Episode).filter_by(station="rns", ep_id=ep_id).first()
if not ep:
ep = Episode(
station="rns", program_slug=program_slug, ep_id=ep_id,
title=title, authors=authors, url=audio_url, image=img_url,
pub_date=pub_date, duration_secs=duration_secs, description=description,
is_broken=False
)
self.db.add(ep)
new_found += 1
else:
if not ep.url and audio_url:
ep.url = audio_url
ep.is_broken = False
# Celowo nie zwiększamy new_found, aby Daily Catchup nie wchodził w tryb głębokiego archiwum.
# Łataniem starych dziur zajmie się faza Backfill.
if cards and not valid_cards:
invalid_response("rns", "HTML_SCHEMA_CHANGED", "Znaleziono karty podcastów bez oczekiwanych identyfikatorów", url, content=html)
if missing_player_count:
warning = f"{missing_player_count} odcinków bez dostępnego playera audio; pomijam ich URL-e."
if self.warning_callback:
self.warning_callback(warning)
else:
self.logger(f"RNŚ: WARNING: {warning}")
self.db.commit()
page_links = soup.find_all("a", href=re.compile(r"page=\d+"))
max_page = page
for link in page_links:
href = link.get("href")
m = re.search(r"page=(\d+)", href)
if m:
max_page = max(max_page, int(m.group(1)))
return new_found, len(cards), max_page
def _compute_next_catchup(self, prog: Program) -> float:
"""Wylicza kiedy najwcześniej warto znowu sprawdzać tę audycję."""
episodes = self.db.query(Episode).filter_by(
station="rns", program_slug=prog.slug
).order_by(Episode.pub_date.desc()).limit(20).all()
if len(episodes) < 3:
# Za mało danych – sprawdzamy przy każdym uruchomieniu
return 0.0
# Wylicz średnią przerwę między odcinkami w dniach
import re
dates = []
for ep in episodes:
if ep.pub_date and re.match(r'\d{4}-\d{2}-\d{2}', ep.pub_date):
try:
import datetime
dates.append(datetime.date.fromisoformat(ep.pub_date[:10]))
except ValueError:
pass
if len(dates) < 3:
return 0.0
dates.sort(reverse=True)
gaps = [(dates[i] - dates[i+1]).days for i in range(len(dates)-1)]
avg_gap = sum(gaps) / len(gaps)
if avg_gap < 2:
# Codziennie lub częściej → zawsze sprawdzamy (brak cooldownu)
return 0.0
elif avg_gap <= 8:
# Tygodniowo → cooldown = 70% cyklu (żeby sprawdzić przed następnym odcinkiem)
cooldown_days = avg_gap * 0.7
else:
# Rzadziej niż tygodniowo → max 7 dni
cooldown_days = 7.0
return time.time() + cooldown_days * 86400
def phase_1_catchup(self, specific_program_slug=None):
self.logger("RNŚ: Phase 1 (Daily Catchup)...")
query = self.db.query(Program).filter_by(station="rns")
if specific_program_slug:
query = query.filter_by(slug=specific_program_slug)
else:
query = query.order_by(Program.last_catchup.asc())
catchup_limit = max(1, self.hard_limit - self.backfill_limit)
skipped = 0
timeout_program = None
for prog in query.all():
if self.requests_made >= catchup_limit:
self.logger(f"RNŚ: [{self.requests_made}/{self.hard_limit}] Limit Catchup osiągnięty. Zostawiam resztę dla Backfill.")
break
# Pomiń jeśli za wcześnie (adaptive cooldown)
if not specific_program_slug and prog.next_catchup_after and time.time() < prog.next_catchup_after:
skipped += 1
continue
is_first_sync = (prog.last_catchup == 0.0)
page = 1
catchup_succeeded = False
while True:
if self.requests_made >= catchup_limit: break
self.logger(f"RNŚ: [{self.requests_made}/{self.hard_limit}] Sprawdzam {prog.slug} (strona {page})")
try:
new_found, total_cards, max_page = self.fetch_program_page(prog.slug, page)
except ScraperError as exc:
if exc.code != "NETWORK_TIMEOUT":
raise
if timeout_program:
raise ScraperError(
"rns", "NETWORK_TIMEOUT",
f"Timeout także dla kolejnej audycji {prog.slug}; poprzednia: {timeout_program}",
exc.url,
) from exc
timeout_program = prog.slug
self.logger(f"RNŚ: Pomijam {prog.slug} po trzech timeoutach i sprawdzam następną audycję.")
break
if timeout_program:
warning = f"Poprzednia audycja {timeout_program} miała trzy timeouty; kolejna audycja odpowiada poprawnie."
if self.warning_callback:
self.warning_callback(warning)
else:
self.logger(f"RNŚ: WARNING: {warning}")
timeout_program = None
if max_page > prog.total_pages:
prog.total_pages = max_page
if total_cards == 0 or new_found == 0:
catchup_succeeded = True
break
if is_first_sync:
catchup_succeeded = True
break
if page >= max_page:
catchup_succeeded = True
break
page += 1
time.sleep(0.5)
if catchup_succeeded:
prog.last_catchup = time.time()
prog.next_catchup_after = self._compute_next_catchup(prog)
self.db.commit()
if skipped:
self.logger(f"RNŚ: Pominięto {skipped} audycji (cooldown adaptacyjny).")
def phase_2_backfill(self, specific_program_slug=None):
backfill_count = 0
if specific_program_slug:
prog = self.db.query(Program).filter_by(station="rns", slug=specific_program_slug).first()
if prog:
page = prog.backfill_page
self.logger(f"RNŚ: [{self.requests_made}/{self.hard_limit}] Backfill manualny dla {prog.slug} (od strony {page})")
while self.requests_made < self.hard_limit and backfill_count < self.backfill_limit:
new_found, total, max_p = self.fetch_program_page(prog.slug, page)
backfill_count += 1
if max_p > prog.total_pages:
prog.total_pages = max_p
self.logger(f"RNŚ: [{self.requests_made}/{self.hard_limit}] Backfill {prog.slug} strona {page} z {prog.total_pages or '?'} ({new_found} nowych)")
if total == 0 or page >= max_p:
prog.backfill_complete = True
self.logger(f"RNŚ: Backfill {prog.slug} – zakończono.")
break
prog.backfill_page = page + 1
page += 1
self.db.commit()
time.sleep(0.5)
return
while self.requests_made < self.hard_limit and backfill_count < self.backfill_limit:
prog = self.db.query(Program).filter_by(station="rns", backfill_complete=False).order_by(Program.backfill_page.asc()).first()
if not prog:
self.logger("RNŚ: Wszystkie audycje w pełni uzupełnione!")
break
page = prog.backfill_page
self.logger(f"RNŚ: [{self.requests_made}/{self.hard_limit}] Backfill dla {prog.slug} (strona {page} z {prog.total_pages or '?'})")
new_found, total_cards, max_p = self.fetch_program_page(prog.slug, page)
backfill_count += 1
if max_p > prog.total_pages:
prog.total_pages = max_p
if total_cards == 0 or page >= max_p:
prog.backfill_complete = True
else:
prog.backfill_page = page + 1
self.db.commit()
time.sleep(0.5)
def run_full_sync(self, specific_program_slug=None):
if not self.ensure_auth():
self.logger("RNŚ: Błąd logowania przed startem!")
raise Exception("RNS Login Failed")
self.update_programs()
self.phase_1_catchup(specific_program_slug)
if self.requests_made < self.hard_limit:
self.phase_2_backfill(specific_program_slug)
self.logger(f"RNŚ: Koniec. Wysłano zapytania: {self.requests_made}/{self.hard_limit} (w tym backfill ograniczony do {self.backfill_limit}).")
+45
View File
@@ -0,0 +1,45 @@
import base64
import hashlib
import os
from cryptography.fernet import Fernet, InvalidToken
_ENCRYPTED_PREFIX = "enc:v1:"
def get_session_secret() -> str:
secret = os.environ.get("RADIOSYNC_SECRET_KEY")
if not secret:
raise RuntimeError("RADIOSYNC_SECRET_KEY must be set")
return secret
def _get_fernet() -> Fernet:
configured_key = os.environ.get("RADIOSYNC_CONFIG_KEY")
if configured_key:
try:
return Fernet(configured_key.encode("ascii"))
except Exception as exc:
raise RuntimeError("RADIOSYNC_CONFIG_KEY must be a valid Fernet key") from exc
digest = hashlib.sha256(get_session_secret().encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def encrypt_config_value(value: str) -> str:
if value is None:
return None
if value.startswith(_ENCRYPTED_PREFIX):
return value
token = _get_fernet().encrypt(value.encode("utf-8")).decode("ascii")
return f"{_ENCRYPTED_PREFIX}{token}"
def decrypt_config_value(value: str) -> str:
if value is None or not value.startswith(_ENCRYPTED_PREFIX):
return value
try:
token = value[len(_ENCRYPTED_PREFIX):].encode("ascii")
return _get_fernet().decrypt(token).decode("utf-8")
except InvalidToken as exc:
raise RuntimeError("Cannot decrypt config value with the configured key") from exc
+13
View File
@@ -0,0 +1,13 @@
services:
radiosync:
build: .
container_name: radiosync_hub
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- ./data:/app/data
environment:
- TZ=Europe/Warsaw
- RADIOSYNC_SECRET_KEY=${RADIOSYNC_SECRET_KEY:?Set RADIOSYNC_SECRET_KEY in .env}
- RADIOSYNC_CONFIG_KEY=${RADIOSYNC_CONFIG_KEY:-}
+613
View File
@@ -0,0 +1,613 @@
import os
import datetime
import threading
import hashlib
import re
import secrets
from fastapi import FastAPI, HTTPException, Depends, Request, Form, BackgroundTasks
from fastapi.responses import HTMLResponse, Response, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from apscheduler.schedulers.background import BackgroundScheduler
from core.database import SessionLocal, init_db, Config, Program, Episode, ErrorEvent
from core.scrapers.radio357 import Radio357Scraper
from core.scrapers.rns import RNScraper
from core.scrapers.jazz import JazzScraper
from core.rss_generator import generate_master_opml, generate_podcast_rss
from core.notifier import send_notification, validate_notification_url
from starlette.middleware.sessions import SessionMiddleware
app = FastAPI(title="RadioSync Hub")
session_secret = os.environ.get("RADIOSYNC_SECRET_KEY")
if not session_secret:
raise RuntimeError("RADIOSYNC_SECRET_KEY must be set")
app.add_middleware(SessionMiddleware, secret_key=session_secret)
# Set up templates
base_dir = os.path.dirname(os.path.abspath(__file__))
templates = Jinja2Templates(directory=os.path.join(base_dir, "templates"))
app.mount("/static", StaticFiles(directory=os.path.join(base_dir, "static")), name="static")
init_db()
scheduler = BackgroundScheduler()
sync_locks = {station: threading.Lock() for station in ("radio357", "rns", "jazz")}
# State variables for logging
sync_status = {
"radio357": {"last_run": None, "status": "Oczekuje", "progress": "", "stop_requested": False},
"rns": {"last_run": None, "status": "Oczekuje", "progress": "", "stop_requested": False},
"jazz": {"last_run": None, "status": "Oczekuje", "progress": "", "stop_requested": False},
"logs": []
}
def add_log(msg: str):
ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sync_status["logs"].insert(0, f"[{ts}] {msg}")
if len(sync_status["logs"]) > 100:
sync_status["logs"].pop()
print(msg)
def db_session():
db = SessionLocal()
try:
yield db
finally:
db.close()
def notify_error(db: Session, station: str, message: str):
url = db.query(Config).filter_by(key="ntfy_url").first()
topic = db.query(Config).filter_by(key="ntfy_topic").first()
user = db.query(Config).filter_by(key="ntfy_user").first()
pw = db.query(Config).filter_by(key="ntfy_password").first()
if url and topic:
try:
validate_notification_url(url.value)
send_notification(url.value, topic.value, f"RadioSync Błąd: {station}", message,
username=user.value if user else None,
password=pw.value if pw else None)
except Exception as exc:
add_log(f"Nie udało się wysłać powiadomienia ntfy: {exc}")
def record_event(db: Session, station: str, message: str, source: str = "sync", severity: str = "error"):
now = datetime.datetime.now()
snapshot_match = re.search(r"Snapshot: (.+?)(?: URL:|$)", message)
snapshot_path = snapshot_match.group(1) if snapshot_match else None
stable_message = re.sub(r"\. Snapshot: .+?(?= URL:|$)", "", message)
fingerprint_message = re.sub(r" URL: https?://\S+", "", stable_message)
fingerprint_message = re.sub(r"\b\d+\b", "<count>", fingerprint_message)
code_match = re.search(r"\[([A-Z0-9_]+)\]", stable_message)
stable_source = code_match.group(1) if code_match else source
fingerprint = hashlib.sha256(f"{station}:{stable_source}:{fingerprint_message}".encode("utf-8")).hexdigest()
try:
event = db.query(ErrorEvent).filter_by(fingerprint=fingerprint).first()
if event:
event.occurrences += 1
event.last_seen = now
event.acknowledged = False
event.acknowledged_at = None
if snapshot_path:
event.snapshot_path = snapshot_path
else:
db.add(ErrorEvent(station=station, source=stable_source, message=stable_message,
severity=severity, fingerprint=fingerprint, first_seen=now,
last_seen=now, snapshot_path=snapshot_path))
db.commit()
except Exception as exc:
db.rollback()
print(f"Failed to persist error event: {exc}")
label = "BŁĄD" if severity == "error" else "WARNING"
add_log(f"[{label}][{station}] {stable_message}")
def record_error(db: Session, station: str, message: str, source: str = "sync"):
record_event(db, station, message, source, severity="error")
def record_warning(db: Session, station: str, message: str, source: str = "sync"):
record_event(db, station, message, source, severity="warning")
def is_user_stop(message: str):
return "Zatrzymano na żądanie użytkownika" in message
def csrf_token(request: Request):
token = request.session.get("csrf_token")
if not token:
token = secrets.token_urlsafe(32)
request.session["csrf_token"] = token
return token
def validate_csrf(request: Request, token: str):
if not token or token != request.session.get("csrf_token"):
raise HTTPException(status_code=403, detail="Nieprawidłowy token CSRF")
def validate_interval(value: str, field: str):
try:
interval = int(value)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=f"{field}: podaj liczbę godzin") from exc
if not 1 <= interval <= 168:
raise HTTPException(status_code=400, detail=f"{field}: zakres to 1-168 godzin")
return interval
def validate_limit(value: str, field: str, maximum: int):
try:
limit = int(value)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=f"{field}: podaj liczbę") from exc
if not 1 <= limit <= maximum:
raise HTTPException(status_code=400, detail=f"{field}: zakres to 1-{maximum}")
return limit
def configured_interval(db: Session, key: str, default: int):
row = db.query(Config).filter_by(key=key).first()
try:
value = int(row.value) if row else default
except (TypeError, ValueError):
return default
return value if 1 <= value <= 168 else default
def station_status_key(station: str):
return {
"Radio 357": "radio357",
"RNŚ": "rns",
"Radio Jazz FM": "jazz",
}.get(station)
def persist_last_run(station: str, dt: datetime.datetime):
"""Zapisuje datę ostatniego udanego syncu do bazy danych."""
db = SessionLocal()
try:
existing = db.query(Config).filter_by(key=f"{station}_last_run").first()
if existing:
existing.value = dt.isoformat()
else:
db.add(Config(key=f"{station}_last_run", value=dt.isoformat()))
db.commit()
finally:
db.close()
def persist_status(station: str, status: str):
db = SessionLocal()
try:
key = f"{station}_status"
existing = db.query(Config).filter_by(key=key).first()
if existing:
existing.value = status
else:
db.add(Config(key=key, value=status))
db.commit()
finally:
db.close()
def set_status(station: str, status: str):
sync_status[station]["status"] = status
persist_status(station, status)
def load_last_runs():
"""Wczytuje daty ostatnich syncronizacji z bazy danych przy starcie."""
db = SessionLocal()
try:
for station in ["radio357", "rns", "jazz"]:
row = db.query(Config).filter_by(key=f"{station}_last_run").first()
if row and row.value:
try:
sync_status[station]["last_run"] = datetime.datetime.fromisoformat(row.value)
except ValueError:
pass
status_row = db.query(Config).filter_by(key=f"{station}_status").first()
if status_row and status_row.value != "W trakcie":
sync_status[station]["status"] = status_row.value
finally:
db.close()
def update_progress(station: str, made: int, limit: int):
sync_status[station]["progress"] = f"{made} / {limit}"
# --- BACKGROUND JOBS ---
def job_sync_radio357(specific_program=None):
if not sync_locks["radio357"].acquire(blocking=False):
add_log("R357: Pomijam synchronizację, ponieważ już trwa.")
return
db = None
try:
set_status("radio357", "W trakcie")
sync_status["radio357"]["progress"] = "0 / ?"
sync_status["radio357"]["stop_requested"] = False
add_log(f"Rozpoczęto synchronizację Radia 357 (program: {specific_program or 'wszystkie'})")
db = SessionLocal()
stop_fn = lambda: sync_status["radio357"]["stop_requested"]
scraper = Radio357Scraper(db, logger=add_log,
progress_callback=lambda m, l: update_progress("radio357", m, l),
stop_flag=stop_fn)
scraper.run_full_sync(specific_program)
now = datetime.datetime.now()
sync_status["radio357"]["last_run"] = now
set_status("radio357", "OK")
persist_last_run("radio357", now)
except Exception as e:
msg = f"Błąd R357: {e}"
if db:
if is_user_stop(msg):
record_warning(db, "Radio 357", "Synchronizacja zatrzymana na żądanie użytkownika.", "user_stop")
else:
record_error(db, "Radio 357", msg)
set_status("radio357", "Przerwano" if is_user_stop(msg) else "Błąd")
if db and not is_user_stop(msg):
notify_error(db, "Radio 357", msg)
finally:
sync_status["radio357"]["progress"] = ""
sync_status["radio357"]["stop_requested"] = False
if db:
db.close()
sync_locks["radio357"].release()
def job_sync_rns(specific_program=None):
if not sync_locks["rns"].acquire(blocking=False):
add_log("RNŚ: Pomijam synchronizację, ponieważ już trwa.")
return
db = None
try:
set_status("rns", "W trakcie")
sync_status["rns"]["progress"] = "0 / ?"
sync_status["rns"]["stop_requested"] = False
add_log(f"Rozpoczęto synchronizację RNŚ (program: {specific_program or 'wszystkie'})")
db = SessionLocal()
stop_fn = lambda: sync_status["rns"]["stop_requested"]
scraper = RNScraper(db, logger=add_log,
progress_callback=lambda m, l: update_progress("rns", m, l),
warning_callback=lambda message: record_warning(db, "RNŚ", message, "missing_player"),
stop_flag=stop_fn)
scraper.run_full_sync(specific_program)
now = datetime.datetime.now()
sync_status["rns"]["last_run"] = now
set_status("rns", "OK")
persist_last_run("rns", now)
except Exception as e:
msg = f"Błąd RNŚ: {e}"
if db:
if is_user_stop(msg):
record_warning(db, "RNŚ", "Synchronizacja zatrzymana na żądanie użytkownika.", "user_stop")
else:
record_error(db, "RNŚ", msg)
set_status("rns", "Przerwano" if is_user_stop(msg) else "Błąd")
if db and not is_user_stop(msg):
notify_error(db, "RNŚ", msg)
finally:
sync_status["rns"]["progress"] = ""
sync_status["rns"]["stop_requested"] = False
if db:
db.close()
sync_locks["rns"].release()
def job_sync_jazz():
if not sync_locks["jazz"].acquire(blocking=False):
add_log("JAZZ: Pomijam synchronizację, ponieważ już trwa.")
return
db = None
try:
set_status("jazz", "W trakcie")
add_log("Rozpoczęto synchronizację Radio Jazz FM")
db = SessionLocal()
scraper = JazzScraper(db, logger=add_log)
scraper.run_full_sync()
sync_status["jazz"]["last_run"] = datetime.datetime.now()
now = datetime.datetime.now()
sync_status["jazz"]["last_run"] = now
set_status("jazz", "OK")
persist_last_run("jazz", now)
except Exception as e:
msg = f"Błąd JAZZ: {e}"
if db:
if is_user_stop(msg):
record_warning(db, "Radio Jazz FM", "Synchronizacja zatrzymana na żądanie użytkownika.", "user_stop")
else:
record_error(db, "Radio Jazz FM", msg)
set_status("jazz", "Przerwano" if is_user_stop(msg) else "Błąd")
if db and not is_user_stop(msg):
notify_error(db, "Radio Jazz FM", msg)
finally:
if db:
db.close()
sync_locks["jazz"].release()
@app.on_event("startup")
def startup_event():
db = SessionLocal()
r357_int = configured_interval(db, "r357_interval", 6)
rns_int = configured_interval(db, "rns_interval", 6)
jazz_int = configured_interval(db, "jazz_interval", 24)
db.close()
scheduler.add_job(job_sync_radio357, 'interval', hours=r357_int, id='sync_r357', max_instances=1, coalesce=True)
scheduler.add_job(job_sync_rns, 'interval', hours=rns_int, id='sync_rns', max_instances=1, coalesce=True)
scheduler.add_job(job_sync_jazz, 'interval', hours=jazz_int, id='sync_jazz', max_instances=1, coalesce=True)
scheduler.start()
load_last_runs()
add_log("Aplikacja i harmonogram uruchomione.")
@app.on_event("shutdown")
def shutdown_event():
scheduler.shutdown()
# --- ROUTES ---
@app.get("/", response_class=HTMLResponse)
def index(request: Request, db: Session = Depends(db_session)):
msg = request.session.pop("msg", None)
stats = {
"r357_progs": db.query(Program).filter_by(station="radio357").count(),
"r357_eps": db.query(Episode).filter_by(station="radio357").count(),
"r357_audio_eps": db.query(Episode).filter_by(station="radio357", is_broken=False).filter(Episode.url.isnot(None)).count(),
"rns_progs": db.query(Program).filter_by(station="rns").count(),
"rns_eps": db.query(Episode).filter_by(station="rns").count(),
"rns_audio_eps": db.query(Episode).filter_by(station="rns", is_broken=False).filter(Episode.url.isnot(None)).count(),
}
config = {c.key: c.value for c in db.query(Config).all()}
pending_errors = db.query(ErrorEvent).filter_by(acknowledged=False).order_by(ErrorEvent.last_seen.desc()).all()
acknowledged_errors = db.query(ErrorEvent).filter_by(acknowledged=True).order_by(ErrorEvent.last_seen.desc()).limit(20).all()
return templates.TemplateResponse(request, "index.html", {
"status": sync_status,
"stats": stats,
"config": config,
"msg": msg,
"pending_errors": pending_errors,
"acknowledged_errors": acknowledged_errors,
"csrf_token": csrf_token(request),
})
@app.post("/errors/{error_id}/acknowledge")
def acknowledge_error(request: Request, error_id: int, csrf: str = Form(...), db: Session = Depends(db_session)):
validate_csrf(request, csrf)
event = db.query(ErrorEvent).filter_by(id=error_id).first()
if event:
event.acknowledged = True
event.acknowledged_at = datetime.datetime.now()
db.commit()
add_log(f"Potwierdzono odczytanie błędu: {event.message}")
status_key = station_status_key(event.station)
pending_for_station = db.query(ErrorEvent).filter_by(
station=event.station, acknowledged=False
).count()
if status_key and pending_for_station == 0 and sync_status[status_key]["status"] == "Błąd":
set_status(status_key, "Oczekuje")
return RedirectResponse(url="/", status_code=303)
@app.post("/config")
def save_config(
request: Request,
csrf: str = Form(...),
r357_email: str = Form(""), r357_password: str = Form(""),
rns_email: str = Form(""), rns_password: str = Form(""),
ntfy_url: str = Form(""), ntfy_topic: str = Form(""),
ntfy_user: str = Form(""), ntfy_password: str = Form(""),
r357_limit: str = Form("600"), rns_limit: str = Form("50"),
r357_hard_limit: str = Form("1000"), rns_hard_limit: str = Form("500"),
r357_interval: str = Form("6"), rns_interval: str = Form("6"), jazz_interval: str = Form("24"),
db: Session = Depends(db_session)
):
validate_csrf(request, csrf)
r357_interval_value = validate_interval(r357_interval, "r357_interval")
rns_interval_value = validate_interval(rns_interval, "rns_interval")
jazz_interval_value = validate_interval(jazz_interval, "jazz_interval")
validate_limit(r357_limit, "r357_limit", 10000)
validate_limit(rns_limit, "rns_limit", 10000)
validate_limit(r357_hard_limit, "r357_hard_limit", 20000)
validate_limit(rns_hard_limit, "rns_hard_limit", 20000)
if ntfy_url:
try:
validate_notification_url(ntfy_url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
import urllib.parse
updates = {
"r357_email": r357_email, "r357_password": r357_password,
"rns_email": rns_email, "rns_password": rns_password,
"ntfy_url": ntfy_url, "ntfy_topic": ntfy_topic,
"ntfy_user": ntfy_user, "ntfy_password": ntfy_password,
"r357_limit": r357_limit, "rns_limit": rns_limit,
"r357_hard_limit": r357_hard_limit, "rns_hard_limit": rns_hard_limit,
"r357_interval": r357_interval, "rns_interval": rns_interval, "jazz_interval": jazz_interval
}
for k, v in updates.items():
if v:
c = db.query(Config).filter_by(key=k).first()
if not c:
db.add(Config(key=k, value=v))
else:
c.value = v
db.commit()
scheduler.reschedule_job('sync_r357', trigger='interval', hours=r357_interval_value)
scheduler.reschedule_job('sync_rns', trigger='interval', hours=rns_interval_value)
scheduler.reschedule_job('sync_jazz', trigger='interval', hours=jazz_interval_value)
if r357_email or r357_password:
db.query(Config).filter(Config.key == "r357_token").delete(synchronize_session=False)
if rns_email or rns_password:
db.query(Config).filter(Config.key == "rns_cookies").delete(synchronize_session=False)
db.commit()
m = "Zapisano nową konfigurację. Harmonogram i sesje zresetowane."
add_log(m)
request.session["msg"] = m
return RedirectResponse(url="/", status_code=303)
@app.post("/test-ntfy")
def test_ntfy(request: Request, csrf: str = Form(...), db: Session = Depends(db_session)):
validate_csrf(request, csrf)
import urllib.parse
notify_error(db, "TEST", "To jest wiadomość testowa z RadioSync Hub.")
m = "Wysłano testowe powiadomienie ntfy."
add_log(m)
request.session["msg"] = m
return RedirectResponse(url="/", status_code=303)
@app.post("/test-auth")
def test_auth(request: Request, station: str = Form(...), csrf: str = Form(...), db: Session = Depends(db_session)):
validate_csrf(request, csrf)
import urllib.parse
m = ""
if station == "radio357":
scraper = Radio357Scraper(db, logger=add_log)
ok, msg = scraper.check_auth_status()
m = f"R357: {'✅' if ok else '❌'} {msg}"
elif station == "rns":
scraper = RNScraper(db, logger=add_log)
ok, msg = scraper.check_auth_status()
m = f"RNŚ: {'✅' if ok else '❌'} {msg}"
add_log(m)
request.session["msg"] = m
return RedirectResponse(url="/", status_code=303)
@app.post("/force-relogin")
def force_relogin(request: Request, station: str = Form(...), csrf: str = Form(...), db: Session = Depends(db_session)):
validate_csrf(request, csrf)
import urllib.parse
add_log(f"Wymuszony relogin dla: {station}...")
m = ""
if station == "radio357":
db.query(Config).filter_by(key="r357_token").delete()
db.commit()
scraper = Radio357Scraper(db, logger=add_log)
if scraper.get_token():
m = "R357: ✅ Relogin poprawny!"
else:
m = "R357: ❌ Relogin nieudany (sprawdź dane)."
elif station == "rns":
db.query(Config).filter_by(key="rns_cookies").delete()
db.commit()
scraper = RNScraper(db, logger=add_log)
if scraper.perform_login():
m = "RNŚ: ✅ Relogin poprawny!"
else:
m = "RNŚ: ❌ Relogin nieudany (sprawdź dane)."
add_log(m)
request.session["msg"] = m
return RedirectResponse(url="/", status_code=303)
@app.post("/force-sync")
def force_sync(request: Request, station: str = Form(...), csrf: str = Form(...), bg_tasks: BackgroundTasks = BackgroundTasks()):
validate_csrf(request, csrf)
import urllib.parse
if station not in sync_locks:
raise HTTPException(status_code=400, detail="Unknown station")
if sync_locks[station].locked():
m = f"Synchronizacja dla {station} już trwa."
add_log(m)
request.session["msg"] = m
return RedirectResponse(url="/", status_code=303)
if station == "radio357":
bg_tasks.add_task(job_sync_radio357)
elif station == "rns":
bg_tasks.add_task(job_sync_rns)
elif station == "jazz":
bg_tasks.add_task(job_sync_jazz)
names = {"radio357": "Radia 357", "rns": "Radia Nowy Świat", "jazz": "Radio Jazz FM"}
m = f"Rozpoczęto w tle synchronizację: {names.get(station, station)}."
add_log(m)
request.session["msg"] = m
return RedirectResponse(url="/", status_code=303)
@app.post("/stop-sync")
def stop_sync(request: Request, station: str = Form(...), csrf: str = Form(...)):
validate_csrf(request, csrf)
if station in sync_status and sync_status[station]["status"] == "W trakcie":
sync_status[station]["stop_requested"] = True
m = f"Wysłano sygnał zatrzymania synchronizacji: {station}."
else:
m = f"Sync dla {station} nie jest aktualnie uruchomiony."
add_log(m)
request.session["msg"] = m
return RedirectResponse(url="/", status_code=303)
@app.post("/force-sync-program")
def force_sync_program(request: Request, station: str = Form(...), slug: str = Form(...), csrf: str = Form(...), bg_tasks: BackgroundTasks = BackgroundTasks()):
validate_csrf(request, csrf)
import urllib.parse
if station not in {"radio357", "rns"}:
raise HTTPException(status_code=400, detail="Station does not support program sync")
if sync_locks[station].locked():
m = f"Synchronizacja dla {station} już trwa."
add_log(m)
request.session["msg"] = m
return RedirectResponse(url=f"/programs/{station}", status_code=303)
if station == "radio357":
bg_tasks.add_task(job_sync_radio357, specific_program=slug)
else:
bg_tasks.add_task(job_sync_rns, specific_program=slug)
m = f"Zlecono wymuszoną synchronizację pojedynczego programu: {slug} ({station})"
add_log(m)
request.session["msg"] = m
return RedirectResponse(url=f"/programs/{station}", status_code=303)
@app.get("/programs/{station}", response_class=HTMLResponse)
def list_programs(request: Request, station: str, db: Session = Depends(db_session)):
msg = request.session.pop("msg", None)
import datetime
progs = db.query(Program).filter_by(station=station).order_by(Program.name).all()
for p in progs:
p.missing_urls_count = db.query(Episode).filter_by(station=station, program_slug=p.slug, url=None).count()
p.total_eps = db.query(Episode).filter_by(station=station, program_slug=p.slug).count()
if p.last_catchup:
p.last_catchup_str = datetime.datetime.fromtimestamp(p.last_catchup).strftime('%Y-%m-%d %H:%M')
else:
p.last_catchup_str = "Nigdy"
return templates.TemplateResponse(request, "programs.html", {"station": station, "programs": progs, "msg": msg, "csrf_token": csrf_token(request)})
@app.get("/episodes/{station}/{slug:path}", response_class=HTMLResponse)
def list_episodes(request: Request, station: str, slug: str, db: Session = Depends(db_session)):
msg = request.session.pop("msg", None)
prog = db.query(Program).filter_by(station=station, slug=slug).first()
if not prog:
raise HTTPException(status_code=404, detail="Program not found")
eps = db.query(Episode).filter_by(station=station, program_slug=slug).order_by(Episode.pub_date.desc()).all()
return templates.TemplateResponse(request, "episodes.html", {"station": station, "program": prog, "episodes": eps, "msg": msg, "csrf_token": csrf_token(request)})
@app.post("/episodes/delete-url")
def delete_episode_url(request: Request, ep_id: int = Form(...), csrf: str = Form(...), db: Session = Depends(db_session)):
validate_csrf(request, csrf)
import urllib.parse
ep = db.query(Episode).filter_by(id=ep_id).first()
if ep:
ep.url = None
ep.is_broken = True
db.commit()
m = f"Usunięto błędny link audio dla odcinka: {ep.title}"
add_log(m)
request.session["msg"] = m
return RedirectResponse(url=f"/episodes/{ep.station}/{ep.program_slug}", status_code=303)
return RedirectResponse(url="/", status_code=303)
# --- RSS ENDPOINTS ---
@app.get("/feeds/Podcasts.opml")
def get_master_opml(request: Request, db: Session = Depends(db_session)):
xml = generate_master_opml(db, str(request.base_url).rstrip('/'))
return Response(content=xml, media_type="application/xml")
@app.get("/feeds/{station}.opml")
def get_station_opml(request: Request, station: str, db: Session = Depends(db_session)):
if station not in {"radio357", "rns", "jazz"}:
raise HTTPException(status_code=404, detail="Station not found")
xml = generate_master_opml(db, str(request.base_url).rstrip('/'), station=station)
return Response(content=xml, media_type="application/xml")
@app.get("/feeds/{station}/{slug_with_ext:path}")
def podcast_rss(station: str, slug_with_ext: str, request: Request, db: Session = Depends(db_session)):
if not slug_with_ext.endswith(".xml"):
raise HTTPException(status_code=404, detail="Not Found")
slug = slug_with_ext[:-4]
xml = generate_podcast_rss(db, station, slug)
if not xml:
return Response(status_code=404)
return Response(content=xml, media_type="application/xml")
+10
View File
@@ -0,0 +1,10 @@
fastapi
uvicorn[standard]
sqlalchemy
jinja2
beautifulsoup4
requests
apscheduler
python-multipart
itsdangerous
cryptography
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#151a1e"/>
<circle cx="32" cy="32" r="6" fill="#29d3e8"/>
<path d="M21 21a16 16 0 0 0 0 22M43 21a16 16 0 0 1 0 22M14 14a26 26 0 0 0 0 36M50 14a26 26 0 0 1 0 36" fill="none" stroke="#29d3e8" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 349 B

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