SalexAI commited on
Commit
acd6309
·
verified ·
1 Parent(s): 30a787a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +137 -1
app.py CHANGED
@@ -1,8 +1,15 @@
1
- from fastapi import FastAPI
2
  from fastapi.middleware.cors import CORSMiddleware
 
 
3
  import httpx
4
  import json
5
  import logging
 
 
 
 
 
6
 
7
  app = FastAPI()
8
  logging.basicConfig(level=logging.INFO)
@@ -15,6 +22,135 @@ app.add_middleware(
15
  allow_headers=["*"],
16
  )
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  BASE_62_MAP = {c: i for i, c in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")}
19
 
20
  async def get_client() -> httpx.AsyncClient:
 
1
+ from fastapi import FastAPI, Request
2
  from fastapi.middleware.cors import CORSMiddleware
3
+ from pydantic import BaseModel, Field
4
+ from typing import Optional, List
5
  import httpx
6
  import json
7
  import logging
8
+ import os
9
+ import asyncio
10
+ import hashlib
11
+ from datetime import datetime, timezone
12
+ import re
13
 
14
  app = FastAPI()
15
  logging.basicConfig(level=logging.INFO)
 
22
  allow_headers=["*"],
23
  )
24
 
25
+ # ---------------------------
26
+ # 📦 Persistent storage setup
27
+ # ---------------------------
28
+ PERSISTENT_ROOT = os.environ.get("PERSISTENT_DIR", "/data")
29
+ CHAT_DIR = os.path.join(PERSISTENT_ROOT, "chat")
30
+ if not os.path.isdir(PERSISTENT_ROOT):
31
+ # Fall back locally if /data isn't available
32
+ CHAT_DIR = os.path.join(".", "data", "chat")
33
+ os.makedirs(CHAT_DIR, exist_ok=True)
34
+
35
+ def _chat_file_for(video_id: str) -> str:
36
+ # Stable filename; avoids path traversal and huge names
37
+ h = hashlib.sha256(video_id.encode("utf-8")).hexdigest()[:32]
38
+ return os.path.join(CHAT_DIR, f"{h}.jsonl")
39
+
40
+ # Per-file asyncio locks (process-local)
41
+ app.state.chat_locks = {}
42
+
43
+ def _lock_for(path: str) -> asyncio.Lock:
44
+ lock = app.state.chat_locks.get(path)
45
+ if lock is None:
46
+ lock = asyncio.Lock()
47
+ app.state.chat_locks[path] = lock
48
+ return lock
49
+
50
+ def _now_iso() -> str:
51
+ return datetime.now(timezone.utc).isoformat()
52
+
53
+ def _valid_author(s: Optional[str]) -> str:
54
+ s = (s or "anon").strip()
55
+ s = re.sub(r"\s+", " ", s)
56
+ return s[:32] or "anon"
57
+
58
+ def _valid_text(s: str) -> str:
59
+ s = (s or "").rstrip("\n")
60
+ return s[:2000]
61
+
62
+ async def _append_jsonl(path: str, record: dict) -> None:
63
+ line = json.dumps(record, ensure_ascii=False) + "\n"
64
+ def _write():
65
+ with open(path, "a", encoding="utf-8") as f:
66
+ f.write(line)
67
+ await asyncio.to_thread(_write)
68
+
69
+ async def _read_jsonl(path: str) -> List[dict]:
70
+ if not os.path.exists(path):
71
+ return []
72
+ def _read():
73
+ with open(path, "r", encoding="utf-8") as f:
74
+ return [json.loads(x) for x in f if x.strip()]
75
+ return await asyncio.to_thread(_read)
76
+
77
+ # ---------------------------
78
+ # 💬 Chat API
79
+ # ---------------------------
80
+ class NewMessage(BaseModel):
81
+ author: Optional[str] = Field(default="anon", max_length=64)
82
+ text: str = Field(..., min_length=1, max_length=5000)
83
+
84
+ @app.get("/chat/{video_id}")
85
+ async def get_messages(video_id: str, limit: int = 50, since: Optional[str] = None):
86
+ """
87
+ Fetch messages for a video.
88
+ - limit: max messages returned (default 50)
89
+ - since: ISO8601 timestamp; return only messages with created_at > since
90
+ """
91
+ limit = max(1, min(limit, 200))
92
+ path = _chat_file_for(video_id)
93
+ items = await _read_jsonl(path)
94
+
95
+ # Filter by 'since' if provided
96
+ if since:
97
+ try:
98
+ since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
99
+ def _is_new(msg):
100
+ try:
101
+ ts = datetime.fromisoformat(str(msg.get("created_at", "")).replace("Z", "+00:00"))
102
+ return ts > since_dt
103
+ except Exception:
104
+ return False
105
+ items = list(filter(_is_new, items))
106
+ except Exception:
107
+ # If bad 'since', just ignore it
108
+ pass
109
+
110
+ # Return the most recent 'limit' messages (sorted ascending by created_at)
111
+ items.sort(key=lambda m: m.get("created_at", ""))
112
+ if len(items) > limit:
113
+ items = items[-limit:]
114
+
115
+ return {"video_id": video_id, "count": len(items), "messages": items}
116
+
117
+ @app.post("/chat/{video_id}")
118
+ async def post_message(video_id: str, msg: NewMessage, request: Request):
119
+ """
120
+ Append a message to a video's chat.
121
+ Body: { "author": "Ross", "text": "hello" }
122
+ """
123
+ path = _chat_file_for(video_id)
124
+ lock = _lock_for(path)
125
+
126
+ author = _valid_author(msg.author)
127
+ text = _valid_text(msg.text)
128
+ if not text:
129
+ return {"ok": False, "error": "Empty message"}
130
+
131
+ created = _now_iso()
132
+ # Lightweight id; good enough for demo
133
+ mid = hashlib.sha1(f"{video_id}|{author}|{created}|{text}".encode("utf-8")).hexdigest()[:16]
134
+ ip = request.client.host if request and request.client else None
135
+
136
+ record = {
137
+ "id": mid,
138
+ "video_id": video_id,
139
+ "author": author,
140
+ "text": text,
141
+ "created_at": created,
142
+ "ip": ip,
143
+ "ua": request.headers.get("user-agent", "")[:200]
144
+ }
145
+
146
+ async with lock:
147
+ await _append_jsonl(path, record)
148
+
149
+ return {"ok": True, "message": record}
150
+
151
+ # ---------------------------
152
+ # (Your original iCloud album endpoints — unchanged)
153
+ # ---------------------------
154
  BASE_62_MAP = {c: i for i, c in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")}
155
 
156
  async def get_client() -> httpx.AsyncClient: