Add graceful shutdown, logging, streaming with Range Requests, and state.json cache

- Graceful shutdown: SIGTERM/SIGINT handlers stop continuous scraper,
  disconnect Telegram client, drain running jobs with timeout
- Logging: replace print() with logging module throughout web server,
  basicConfig in main.py with structured log format
- Streaming: chunked file reads (64KB) instead of loading entire files
  into memory; full HTTP Range Request support (206 Partial Content)
  for video seeking in the browser
- Cache: TTL-based in-memory cache (1s) for state.json reads in
  StateStore, thread-safe with existing RLock
- Bugfix: send_error_json now converts HTTPStatus to int for JSON
This commit is contained in:
2026-05-27 00:14:26 +02:00
parent 856d04bd16
commit 1dfe41950e
4 changed files with 171 additions and 20 deletions
+21 -4
View File
@@ -1,7 +1,8 @@
import json
import threading
import time
from pathlib import Path
from typing import Any, Callable, Dict
from typing import Any, Callable, Dict, Optional
DEFAULT_STATE: Dict[str, Any] = {
@@ -24,17 +25,32 @@ class StateStore:
def __init__(self, path: Path):
self.path = path
self.lock = threading.RLock()
self._cache: Optional[Dict[str, Any]] = None
self._cache_time: float = 0
self._cache_ttl: float = 1.0
def load(self) -> Dict[str, Any]:
with self.lock:
now = time.time()
if self._cache is not None and (now - self._cache_time) < self._cache_ttl:
return dict(self._cache)
if not self.path.exists():
return self._default_state()
result = self._default_state()
self._cache = result
self._cache_time = now
return result
try:
with self.path.open("r", encoding="utf-8") as handle:
state = json.load(handle)
except (json.JSONDecodeError, OSError):
return self._default_state()
return self._merge_defaults(state)
result = self._default_state()
self._cache = result
self._cache_time = now
return result
result = self._merge_defaults(state)
self._cache = result
self._cache_time = now
return result
def save(self, state: Dict[str, Any]) -> None:
with self.lock:
@@ -44,6 +60,7 @@ class StateStore:
json.dump(self._merge_defaults(state), handle, ensure_ascii=False, indent=2)
handle.write("\n")
tmp_path.replace(self.path)
self._cache = None
def update(self, mutator: Callable[[Dict[str, Any]], None]) -> Dict[str, Any]:
with self.lock:
+9
View File
@@ -1,7 +1,16 @@
import logging
import sys
from webui_server import run_server
def main():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
stream=sys.stdout,
)
run_server()
+4 -1
View File
@@ -1,8 +1,11 @@
import asyncio
import logging
from typing import Any, Dict, List
from app_state import StateStore
logger = logging.getLogger(__name__)
class ScraperJobService:
def __init__(self, state_store: StateStore):
@@ -16,7 +19,7 @@ class ScraperJobService:
state["scrape_media"] = value
self.state_store.update(mutate)
print(f"Media scraping set to {value}")
logger.info("Media scraping set to %s", value)
return
asyncio.run(self._run_async(job_type, payload))
+137 -15
View File
@@ -3,10 +3,12 @@ import base64
import contextlib
import io
import json
import logging
import mimetypes
import os
import posixpath
import queue
import signal
import sqlite3
import threading
import time
@@ -40,6 +42,8 @@ SESSION_DIR.mkdir(exist_ok=True)
STATE_STORE = StateStore(STATE_FILE)
SCRAPER_JOBS = ScraperJobService(STATE_STORE)
logger = logging.getLogger(__name__)
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@@ -238,10 +242,13 @@ class JobRunner:
self.job_order: List[str] = []
self.queue: "queue.Queue[Job]" = queue.Queue()
self.lock = threading.Lock()
self._shutdown_flag = False
self.worker = threading.Thread(target=self._run, daemon=True)
self.worker.start()
def create_job(self, job_type: str, title: str, payload: Dict[str, Any]) -> Job:
if self._shutdown_flag:
raise RuntimeError("Server is shutting down, cannot create new jobs")
job_id = f"job-{int(time.time() * 1000)}"
job = Job(job_id=job_id, job_type=job_type, title=title, payload=payload)
with self.lock:
@@ -260,9 +267,36 @@ class JobRunner:
job = self.jobs.get(job_id)
return job.to_dict() if job else None
def shutdown(self, timeout: float = 10.0) -> None:
self._shutdown_flag = True
running_jobs = []
with self.lock:
for job_id, job in list(self.jobs.items()):
if job.status == "running":
running_jobs.append(job)
if running_jobs:
logger.warning("Waiting for %d running job(s) to finish...", len(running_jobs))
deadline = time.time() + timeout
while time.time() < deadline:
with self.lock:
still_running = [j for j in running_jobs if j.status == "running"]
if not still_running:
break
time.sleep(0.2)
still_running = [j for j in running_jobs if j.status == "running"]
if still_running:
logger.warning(
"%d job(s) did not finish within %ss timeout",
len(still_running),
timeout,
)
def _run(self) -> None:
while True:
job = self.queue.get()
while not self._shutdown_flag:
try:
job = self.queue.get(timeout=1)
except queue.Empty:
continue
self._execute(job)
self.queue.task_done()
@@ -484,6 +518,19 @@ class TelegramAuthManager:
def submit_password(self, password: str) -> Dict[str, Any]:
return self._run(self._submit_password(password))
def shutdown(self, timeout: float = 5.0) -> None:
async def _disconnect():
if self.client:
await self.client.disconnect()
if self.client:
try:
future = asyncio.run_coroutine_threadsafe(_disconnect(), self.loop)
future.result(timeout=timeout)
except Exception:
pass
self.loop.call_soon_threadsafe(self.loop.stop)
self.thread.join(timeout=timeout)
class ContinuousScrapeManager:
def __init__(self) -> None:
@@ -1321,13 +1368,67 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
def serve_file(
self, file_path: Path, content_type: str, head_only: bool = False
) -> None:
data = file_path.read_bytes()
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
if not head_only:
self.wfile.write(data)
stat = file_path.stat()
file_size = stat.st_size
last_modified = stat.st_mtime
range_header = self.headers.get("Range", "").strip()
if range_header.startswith("bytes="):
try:
start, end = self._parse_range(range_header, file_size)
except ValueError:
self.send_error_json(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE, "Invalid range")
return
content_length = end - start + 1
self.send_response(HTTPStatus.PARTIAL_CONTENT)
self.send_header("Content-Type", content_type)
self.send_header("Accept-Ranges", "bytes")
self.send_header("Last-Modified", time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last_modified)
))
self.send_header("Content-Range", f"bytes {start}-{end}/{file_size}")
self.send_header("Content-Length", str(content_length))
self.end_headers()
if not head_only:
self._write_file_range(file_path, start, content_length)
else:
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type)
self.send_header("Accept-Ranges", "bytes")
self.send_header("Last-Modified", time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last_modified)
))
self.send_header("Content-Length", str(file_size))
self.end_headers()
if not head_only:
self._write_file_range(file_path, 0, file_size)
def _parse_range(self, range_header: str, file_size: int) -> tuple[int, int]:
range_val = range_header.removeprefix("bytes=").strip()
if "-" not in range_val:
raise ValueError("missing dash")
parts = range_val.split("-", 1)
if parts[0] == "":
end = int(parts[1])
start = max(0, file_size - end)
else:
start = int(parts[0])
end = int(parts[1]) if parts[1] else file_size - 1
if start < 0 or start >= file_size or end >= file_size or start > end:
raise ValueError("out of bounds")
return start, end
def _write_file_range(self, file_path: Path, offset: int, length: int) -> None:
chunk_size = 65536
with open(file_path, "rb") as f:
f.seek(offset)
remaining = length
while remaining > 0:
chunk = f.read(min(chunk_size, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
def send_json(self, payload: Any, status: HTTPStatus = HTTPStatus.OK) -> None:
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
@@ -1338,10 +1439,15 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
self.wfile.write(raw)
def send_error_json(self, status: HTTPStatus, message: str) -> None:
self.send_json({"error": message, "status": status}, status=status)
self.send_json({"error": message, "status": int(status)}, status=status)
def log_message(self, format: str, *args: Any) -> None:
return
logger.info(
"%s %s%s",
self.command,
self.path,
args[1] if len(args) > 1 else "-",
)
class TelegramScraperWebServer(ThreadingHTTPServer):
@@ -1356,14 +1462,30 @@ class TelegramScraperWebServer(ThreadingHTTPServer):
def run_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None:
server = TelegramScraperWebServer((host, port))
print(f"Telegram Scraper Web UI listening on http://{host}:{port}")
print("Press Ctrl+C to stop.")
shutdown_event = threading.Event()
def _shutdown(signum=None, frame=None):
if shutdown_event.is_set():
return
logger.warning("Shutdown signal received, shutting down gracefully...")
shutdown_event.set()
server.continuous_manager.stop()
server.auth_manager.shutdown()
server.job_runner.shutdown()
signal.signal(signal.SIGTERM, _shutdown)
signal.signal(signal.SIGINT, _shutdown)
logger.info("Telegram Scraper Web UI listening on http://%s:%s", host, port)
logger.info("Press Ctrl+C to stop.")
try:
server.serve_forever()
while not shutdown_event.is_set():
server.handle_request()
except KeyboardInterrupt:
pass
_shutdown()
finally:
server.server_close()
logger.info("Server stopped.")
if __name__ == "__main__":