from flask import Flask, request, redirect
import os, json, time, requests
from pathlib import Path
from redis import Redis
from rq import Queue
MEILI_URL = os.getenv("MEILI_URL", "http://meili:7700")
MEILI_KEY = os.getenv("MEILI_KEY", "") # from .env
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
app = Flask(__name__)
FEED_LOG = Path(os.getenv("TRANSCRIPT_ROOT", "/transcripts")) / "_feed.log"
q = Queue(connection=Redis.from_url(REDIS_URL))
PAGE = """
PodX - unified search
PodX
Batch
Unified search (podcasts + PDFs + EPUB + Kiwix + Web)
Recent jobs
"""
def read_feed_tail(max_lines: int = 200):
if not FEED_LOG.exists():
return []
try:
with open(FEED_LOG, "rb") as f:
try:
f.seek(-65536, 2) # read last ~64KB
except OSError:
f.seek(0)
data = f.read().decode("utf-8", errors="ignore")
except Exception:
return []
lines = [x.strip() for x in data.splitlines() if x.strip()]
events = []
for ln in lines[-max_lines:]:
try:
events.append(json.loads(ln))
except Exception:
pass
return events
@app.get("/api/status")
def api_status():
events = read_feed_tail(200)
last = events[-1] if events else {}
summary = {
"last_status": last.get("status"),
"last_title": last.get("title") or last.get("path") or last.get("url"),
"last_time": int(time.time()),
"count": len(events),
}
return {"ok": True, "summary": summary, "events": events}
def meili_search(qstr, limit=30):
if not qstr.strip():
return []
try:
r = requests.post(
f"{MEILI_URL}/indexes/library/search",
headers={"Authorization": f"Bearer {MEILI_KEY}", "Content-Type": "application/json"},
data=json.dumps({"q": qstr, "limit": limit}),
timeout=5,
)
if r.status_code != 200:
return []
return r.json().get("hits", [])
except Exception:
return []
@app.get("/health")
def health():
return "ok"
@app.get("/")
def index():
return PAGE
@app.post("/enqueue")
def enqueue():
url = request.form["url"].strip()
q.enqueue("worker.handle_url", url)
return redirect("/")
@app.post("/enqueue_batch")
def enqueue_batch():
urls = [u.strip() for u in request.form["urls"].splitlines() if u.strip()]
for u in urls: q.enqueue("worker.handle_url", u)
return redirect("/")
@app.get("/recent")
def recent():
try:
with open("/transcripts/_feed.log", "r", encoding="utf-8") as f:
tail = f.readlines()[-40:]
except FileNotFoundError:
tail=[]
html = []
for line in reversed(tail):
try: item = json.loads(line)
except: continue
html.append(f"{item.get('title','')} {item.get('uploader','')} — {item.get('date','')} — {item.get('status','')} {item.get('path','')}
")
return "\n".join(html)
@app.get("/search")
def search():
qstr = request.args.get("q","")
hits = meili_search(qstr)
out=[]
for h in hits:
t = h.get("title","")
src = h.get("source","")
typ = h.get("type","")
ctx = h.get("_formatted",{}).get("text", h.get("text","")[:300])
segs = h.get("segments",[])
ts = int(segs[0]["start"]) if segs else 0
if typ == 'podcast':
open_link = f"/open?file={requests.utils.quote(src)}&t={ts}"
else:
open_link = f"/open?file={requests.utils.quote(src)}"
transcript_link = f" | Transcript " if typ == 'podcast' else ""
badge = f"{typ} "
out.append(
f"{badge}{t} {src} "
f"
{ctx}
"
f"
Open "
f"{transcript_link}"
f"
"
)
return "\n".join(out) or "No results yet. "
@app.get("/open")
def open_local():
file = request.args.get("file","")
t = int(request.args.get("t","0"))
return f"{file}\nStart at: {t} sec "
@app.get("/subtitle")
def subtitle():
file = request.args.get("file","")
base = os.path.splitext(os.path.basename(file))[0]
p = f"/transcripts/{base}.vtt"
if os.path.exists(p):
with open(p,"r",encoding="utf-8") as f:
return f"{f.read()} "
return "No VTT found. "