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:
+21
-4
@@ -1,7 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_STATE: Dict[str, Any] = {
|
DEFAULT_STATE: Dict[str, Any] = {
|
||||||
@@ -24,17 +25,32 @@ class StateStore:
|
|||||||
def __init__(self, path: Path):
|
def __init__(self, path: Path):
|
||||||
self.path = path
|
self.path = path
|
||||||
self.lock = threading.RLock()
|
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]:
|
def load(self) -> Dict[str, Any]:
|
||||||
with self.lock:
|
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():
|
if not self.path.exists():
|
||||||
return self._default_state()
|
result = self._default_state()
|
||||||
|
self._cache = result
|
||||||
|
self._cache_time = now
|
||||||
|
return result
|
||||||
try:
|
try:
|
||||||
with self.path.open("r", encoding="utf-8") as handle:
|
with self.path.open("r", encoding="utf-8") as handle:
|
||||||
state = json.load(handle)
|
state = json.load(handle)
|
||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
return self._default_state()
|
result = self._default_state()
|
||||||
return self._merge_defaults(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:
|
def save(self, state: Dict[str, Any]) -> None:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -44,6 +60,7 @@ class StateStore:
|
|||||||
json.dump(self._merge_defaults(state), handle, ensure_ascii=False, indent=2)
|
json.dump(self._merge_defaults(state), handle, ensure_ascii=False, indent=2)
|
||||||
handle.write("\n")
|
handle.write("\n")
|
||||||
tmp_path.replace(self.path)
|
tmp_path.replace(self.path)
|
||||||
|
self._cache = None
|
||||||
|
|
||||||
def update(self, mutator: Callable[[Dict[str, Any]], None]) -> Dict[str, Any]:
|
def update(self, mutator: Callable[[Dict[str, Any]], None]) -> Dict[str, Any]:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
from webui_server import run_server
|
from webui_server import run_server
|
||||||
|
|
||||||
|
|
||||||
def main():
|
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()
|
run_server()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -1,8 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from app_state import StateStore
|
from app_state import StateStore
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ScraperJobService:
|
class ScraperJobService:
|
||||||
def __init__(self, state_store: StateStore):
|
def __init__(self, state_store: StateStore):
|
||||||
@@ -16,7 +19,7 @@ class ScraperJobService:
|
|||||||
state["scrape_media"] = value
|
state["scrape_media"] = value
|
||||||
|
|
||||||
self.state_store.update(mutate)
|
self.state_store.update(mutate)
|
||||||
print(f"Media scraping set to {value}")
|
logger.info("Media scraping set to %s", value)
|
||||||
return
|
return
|
||||||
|
|
||||||
asyncio.run(self._run_async(job_type, payload))
|
asyncio.run(self._run_async(job_type, payload))
|
||||||
|
|||||||
+137
-15
@@ -3,10 +3,12 @@ import base64
|
|||||||
import contextlib
|
import contextlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import posixpath
|
import posixpath
|
||||||
import queue
|
import queue
|
||||||
|
import signal
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -40,6 +42,8 @@ SESSION_DIR.mkdir(exist_ok=True)
|
|||||||
STATE_STORE = StateStore(STATE_FILE)
|
STATE_STORE = StateStore(STATE_FILE)
|
||||||
SCRAPER_JOBS = ScraperJobService(STATE_STORE)
|
SCRAPER_JOBS = ScraperJobService(STATE_STORE)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def utc_now_iso() -> str:
|
def utc_now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
@@ -238,10 +242,13 @@ class JobRunner:
|
|||||||
self.job_order: List[str] = []
|
self.job_order: List[str] = []
|
||||||
self.queue: "queue.Queue[Job]" = queue.Queue()
|
self.queue: "queue.Queue[Job]" = queue.Queue()
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
|
self._shutdown_flag = False
|
||||||
self.worker = threading.Thread(target=self._run, daemon=True)
|
self.worker = threading.Thread(target=self._run, daemon=True)
|
||||||
self.worker.start()
|
self.worker.start()
|
||||||
|
|
||||||
def create_job(self, job_type: str, title: str, payload: Dict[str, Any]) -> Job:
|
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_id = f"job-{int(time.time() * 1000)}"
|
||||||
job = Job(job_id=job_id, job_type=job_type, title=title, payload=payload)
|
job = Job(job_id=job_id, job_type=job_type, title=title, payload=payload)
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -260,9 +267,36 @@ class JobRunner:
|
|||||||
job = self.jobs.get(job_id)
|
job = self.jobs.get(job_id)
|
||||||
return job.to_dict() if job else None
|
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:
|
def _run(self) -> None:
|
||||||
while True:
|
while not self._shutdown_flag:
|
||||||
job = self.queue.get()
|
try:
|
||||||
|
job = self.queue.get(timeout=1)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
self._execute(job)
|
self._execute(job)
|
||||||
self.queue.task_done()
|
self.queue.task_done()
|
||||||
|
|
||||||
@@ -484,6 +518,19 @@ class TelegramAuthManager:
|
|||||||
def submit_password(self, password: str) -> Dict[str, Any]:
|
def submit_password(self, password: str) -> Dict[str, Any]:
|
||||||
return self._run(self._submit_password(password))
|
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:
|
class ContinuousScrapeManager:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -1321,13 +1368,67 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|||||||
def serve_file(
|
def serve_file(
|
||||||
self, file_path: Path, content_type: str, head_only: bool = False
|
self, file_path: Path, content_type: str, head_only: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
data = file_path.read_bytes()
|
stat = file_path.stat()
|
||||||
self.send_response(HTTPStatus.OK)
|
file_size = stat.st_size
|
||||||
self.send_header("Content-Type", content_type)
|
last_modified = stat.st_mtime
|
||||||
self.send_header("Content-Length", str(len(data)))
|
|
||||||
self.end_headers()
|
range_header = self.headers.get("Range", "").strip()
|
||||||
if not head_only:
|
if range_header.startswith("bytes="):
|
||||||
self.wfile.write(data)
|
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:
|
def send_json(self, payload: Any, status: HTTPStatus = HTTPStatus.OK) -> None:
|
||||||
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
@@ -1338,10 +1439,15 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
|
|||||||
self.wfile.write(raw)
|
self.wfile.write(raw)
|
||||||
|
|
||||||
def send_error_json(self, status: HTTPStatus, message: str) -> None:
|
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:
|
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):
|
class TelegramScraperWebServer(ThreadingHTTPServer):
|
||||||
@@ -1356,14 +1462,30 @@ class TelegramScraperWebServer(ThreadingHTTPServer):
|
|||||||
|
|
||||||
def run_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None:
|
def run_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None:
|
||||||
server = TelegramScraperWebServer((host, port))
|
server = TelegramScraperWebServer((host, port))
|
||||||
print(f"Telegram Scraper Web UI listening on http://{host}:{port}")
|
shutdown_event = threading.Event()
|
||||||
print("Press Ctrl+C to stop.")
|
|
||||||
|
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:
|
try:
|
||||||
server.serve_forever()
|
while not shutdown_event.is_set():
|
||||||
|
server.handle_request()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
_shutdown()
|
||||||
finally:
|
finally:
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
logger.info("Server stopped.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user