fix(cors): add popcornminisite.github.io to allowed origins

#1
.gitignore DELETED
@@ -1,4 +0,0 @@
1
-
2
- static/
3
- static/
4
- static/
 
 
 
 
 
__pycache__/app.cpython-310.pyc ADDED
Binary file (21 kB). View file
 
app.py CHANGED
@@ -1,15 +1,21 @@
1
- import json, time, asyncio, aiohttp, os
 
 
 
2
  from contextlib import asynccontextmanager
 
3
  from fastapi import FastAPI, Request, HTTPException
4
  from fastapi.middleware.cors import CORSMiddleware
5
  from fastapi.responses import JSONResponse
6
  from slowapi import _rate_limit_exceeded_handler
7
  from slowapi.errors import RateLimitExceeded
 
8
  from config import ORG, ACCOUNTS, CORS_ORIGINS, DEV_MODE
9
  from database import init_tables, exec, close_session
10
  from db_init import INIT_SQLS, INDEX_SQLS, FTS_SQLS, SEED_SQLS
11
  from ratelimit import limiter
12
 
 
13
  _KEEP_WARM_URLS = [
14
  "https://mml-analysing-shyper-and-api-mid-structer-db.hf.space/health",
15
  "https://multimedia-cryptography-benchmarks-proxy.hf.space/health",
@@ -18,6 +24,7 @@ _KEEP_WARM_URLS = [
18
  "https://Dgntsnfangskgsjafjyjysjtsjtsjwtjfsjyejyrky-proxy.hf.space/health",
19
  ]
20
 
 
21
  async def keep_warm():
22
  while True:
23
  await asyncio.sleep(240)
@@ -25,8 +32,11 @@ async def keep_warm():
25
  for url in _KEEP_WARM_URLS:
26
  try:
27
  async with sess.get(url) as resp:
28
- if resp.status == 200: await resp.read()
29
- except: pass
 
 
 
30
 
31
  _MIGRATIONS = [
32
  "ALTER TABLE archives ADD COLUMN media_id INTEGER",
@@ -35,6 +45,13 @@ _MIGRATIONS = [
35
  "ALTER TABLE archives ADD COLUMN dataset_name TEXT",
36
  "ALTER TABLE archives ADD COLUMN account_key TEXT",
37
  "ALTER TABLE archives ADD COLUMN status TEXT DEFAULT 'archived'",
 
 
 
 
 
 
 
38
  "ALTER TABLE archives ADD COLUMN storage_channel_id INTEGER",
39
  "ALTER TABLE archives ADD COLUMN storage_msg_id INTEGER",
40
  ]
@@ -43,66 +60,108 @@ from routers import health, movies, series, search, stream
43
  from routers import auth_r, user, social, chat, parties
44
  from routers import store, payments
45
  from routers import notifications, achievements, leaderboard, comments
46
- from routers import archive, websocket as ws_router
47
- from routers import social_feed, clubs, gamification, analytics, media as media_router
48
 
49
  _CACHEABLE_PATHS = (
50
- "/api/v1/health", "/api/v1/movies/trending", "/api/v1/movies/featured",
51
- "/api/v1/movies/latest", "/api/v1/movies/genres", "/api/v1/stream/proxy/status",
 
 
 
 
52
  )
53
- _INMEM_CACHE, _INMEM_TTL = {}, {}
 
 
 
54
 
55
  async def cache_middleware(request: Request, call_next):
56
  path = request.url.path
57
  if request.method != "GET" or path == "/favicon.ico":
58
  return await call_next(request)
 
59
  now = time.time()
60
  cached = _INMEM_CACHE.get(path)
61
  if cached and now < _INMEM_TTL.get(path, 0):
62
- return JSONResponse(content=cached, headers={"Cache-Control": "public, max-age=120","X-Cache":"HIT","Access-Control-Allow-Origin":"*"})
 
 
 
 
 
63
  response = await call_next(request)
64
- if response.status_code != 200: return response
 
 
65
  cached_ttl = 30
66
  for prefix in _CACHEABLE_PATHS:
67
- if path.startswith(prefix): cached_ttl = 120; break
 
 
 
68
  try:
69
  body = b"".join([chunk async for chunk in response.body_iterator])
 
 
 
 
 
70
  parsed = json.loads(body)
71
- new_headers = dict(response.headers)
72
- new_headers["Cache-Control"] = f"public, max-age={cached_ttl}"
73
- new_resp = JSONResponse(content=parsed, status_code=response.status_code, headers=new_headers)
74
  _INMEM_CACHE[path] = parsed
75
  _INMEM_TTL[path] = now + cached_ttl
76
- return new_resp
77
  except:
78
- response.headers["Cache-Control"] = f"public, max-age={cached_ttl}"
79
- return response
 
 
 
80
 
81
  @asynccontextmanager
82
  async def lifespan(app: FastAPI):
83
  task = asyncio.create_task(keep_warm())
84
  try:
85
- for sqls in [INIT_SQLS, INDEX_SQLS, FTS_SQLS, SEED_SQLS]:
86
- await init_tables(sqls)
 
 
 
87
  for migration in _MIGRATIONS:
88
- try: await exec(migration)
89
- except: pass
90
- try: await exec("UPDATE archives SET status='archived' WHERE status IS NULL OR status=''")
91
- except: pass
92
- except Exception as e: print(f"[init] error: {e}")
 
 
 
 
 
 
93
  yield
94
  task.cancel()
95
- try: await task
96
- except: pass
 
 
97
  await close_session()
98
 
99
- app = FastAPI(title="PopCorn API", version="3.5", lifespan=lifespan)
 
 
100
  app.state.limiter = limiter
101
  app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
102
- app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"])
 
 
 
 
 
 
 
 
103
  app.middleware("http")(cache_middleware)
104
 
105
- # API Routes
106
  app.include_router(health.router, prefix="/api/v1", tags=["health"])
107
  app.include_router(auth_r.router, prefix="/api/v1/auth", tags=["auth"])
108
  app.include_router(movies.router, prefix="/api/v1/movies", tags=["movies"])
@@ -120,22 +179,88 @@ app.include_router(achievements.router, prefix="/api/v1/achievements", tags=["ac
120
  app.include_router(leaderboard.router, prefix="/api/v1/leaderboard", tags=["leaderboard"])
121
  app.include_router(comments.router, tags=["comments"])
122
  app.include_router(archive.router, prefix="/api/v1/archive", tags=["archive"])
123
- app.include_router(ws_router.router, prefix="/api/v1/ws", tags=["websocket"])
124
- app.include_router(social_feed.router, prefix="/api/v1/social", tags=["social-feed"])
125
- app.include_router(clubs.router, prefix="/api/v1", tags=["clubs"])
126
- app.include_router(gamification.router, prefix="/api/v1/game", tags=["gamification"])
127
- app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
128
- app.include_router(media_router.router, prefix="/api/v1/media", tags=["media"])
129
 
130
  @app.get("/")
131
  async def root():
132
- return {"status":"ok","org":ORG,"version":"3.5"}
 
133
 
134
  @app.get("/health")
135
  @limiter.limit("30/minute")
136
  async def root_health(request: Request):
137
- return {"ok":True,"data":{"status":"healthy","version":"3.5"}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
  @app.exception_handler(Exception)
140
  async def global_exception_handler(request, exc):
141
- return JSONResponse(status_code=500, content={"ok":False,"error":str(exc)[:300]})
 
1
+ import json
2
+ import time
3
+ import asyncio
4
+ import aiohttp
5
  from contextlib import asynccontextmanager
6
+
7
  from fastapi import FastAPI, Request, HTTPException
8
  from fastapi.middleware.cors import CORSMiddleware
9
  from fastapi.responses import JSONResponse
10
  from slowapi import _rate_limit_exceeded_handler
11
  from slowapi.errors import RateLimitExceeded
12
+
13
  from config import ORG, ACCOUNTS, CORS_ORIGINS, DEV_MODE
14
  from database import init_tables, exec, close_session
15
  from db_init import INIT_SQLS, INDEX_SQLS, FTS_SQLS, SEED_SQLS
16
  from ratelimit import limiter
17
 
18
+
19
  _KEEP_WARM_URLS = [
20
  "https://mml-analysing-shyper-and-api-mid-structer-db.hf.space/health",
21
  "https://multimedia-cryptography-benchmarks-proxy.hf.space/health",
 
24
  "https://Dgntsnfangskgsjafjyjysjtsjtsjwtjfsjyejyrky-proxy.hf.space/health",
25
  ]
26
 
27
+
28
  async def keep_warm():
29
  while True:
30
  await asyncio.sleep(240)
 
32
  for url in _KEEP_WARM_URLS:
33
  try:
34
  async with sess.get(url) as resp:
35
+ if resp.status == 200:
36
+ await resp.read()
37
+ except:
38
+ pass
39
+
40
 
41
  _MIGRATIONS = [
42
  "ALTER TABLE archives ADD COLUMN media_id INTEGER",
 
45
  "ALTER TABLE archives ADD COLUMN dataset_name TEXT",
46
  "ALTER TABLE archives ADD COLUMN account_key TEXT",
47
  "ALTER TABLE archives ADD COLUMN status TEXT DEFAULT 'archived'",
48
+ "ALTER TABLE archives ADD COLUMN season_number INTEGER",
49
+ "ALTER TABLE archives ADD COLUMN episode_number INTEGER",
50
+ "ALTER TABLE archives ADD COLUMN source TEXT",
51
+ "ALTER TABLE archives ADD COLUMN audio TEXT",
52
+ "ALTER TABLE archives ADD COLUMN network TEXT",
53
+ "ALTER TABLE archives ADD COLUMN highlights TEXT",
54
+ "ALTER TABLE archives ADD COLUMN full_metadata TEXT",
55
  "ALTER TABLE archives ADD COLUMN storage_channel_id INTEGER",
56
  "ALTER TABLE archives ADD COLUMN storage_msg_id INTEGER",
57
  ]
 
60
  from routers import auth_r, user, social, chat, parties
61
  from routers import store, payments
62
  from routers import notifications, achievements, leaderboard, comments
63
+ from routers import archive
64
+
65
 
66
  _CACHEABLE_PATHS = (
67
+ "/api/v1/health",
68
+ "/api/v1/movies/trending",
69
+ "/api/v1/movies/featured",
70
+ "/api/v1/movies/latest",
71
+ "/api/v1/movies/genres",
72
+ "/api/v1/stream/proxy/status",
73
  )
74
+
75
+ _INMEM_CACHE = {}
76
+ _INMEM_TTL = {}
77
+
78
 
79
  async def cache_middleware(request: Request, call_next):
80
  path = request.url.path
81
  if request.method != "GET" or path == "/favicon.ico":
82
  return await call_next(request)
83
+
84
  now = time.time()
85
  cached = _INMEM_CACHE.get(path)
86
  if cached and now < _INMEM_TTL.get(path, 0):
87
+ return JSONResponse(content=cached, headers={
88
+ "Cache-Control": "public, max-age=120",
89
+ "X-Cache": "HIT",
90
+ "Access-Control-Allow-Origin": "*",
91
+ })
92
+
93
  response = await call_next(request)
94
+ if response.status_code != 200:
95
+ return response
96
+
97
  cached_ttl = 30
98
  for prefix in _CACHEABLE_PATHS:
99
+ if path.startswith(prefix):
100
+ cached_ttl = 120
101
+ break
102
+
103
  try:
104
  body = b"".join([chunk async for chunk in response.body_iterator])
105
+ response = JSONResponse(
106
+ content=json.loads(body),
107
+ status_code=response.status_code,
108
+ headers=dict(response.headers),
109
+ )
110
  parsed = json.loads(body)
 
 
 
111
  _INMEM_CACHE[path] = parsed
112
  _INMEM_TTL[path] = now + cached_ttl
 
113
  except:
114
+ pass
115
+
116
+ response.headers["Cache-Control"] = f"public, max-age={cached_ttl}"
117
+ return response
118
+
119
 
120
  @asynccontextmanager
121
  async def lifespan(app: FastAPI):
122
  task = asyncio.create_task(keep_warm())
123
  try:
124
+ r = await init_tables(INIT_SQLS)
125
+ i = await init_tables(INDEX_SQLS)
126
+ f = await init_tables(FTS_SQLS)
127
+ s = await init_tables(SEED_SQLS)
128
+
129
  for migration in _MIGRATIONS:
130
+ try:
131
+ await exec(migration)
132
+ except Exception:
133
+ pass
134
+
135
+ try:
136
+ await exec("UPDATE archives SET status='archived' WHERE status IS NULL OR status=''")
137
+ except Exception:
138
+ pass
139
+ except Exception as e:
140
+ print(f"[init] error: {e}")
141
  yield
142
  task.cancel()
143
+ try:
144
+ await task
145
+ except:
146
+ pass
147
  await close_session()
148
 
149
+
150
+ app = FastAPI(title="PopCorn Unified API", version="3.0.0", lifespan=lifespan)
151
+
152
  app.state.limiter = limiter
153
  app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
154
+
155
+ app.add_middleware(
156
+ CORSMiddleware,
157
+ allow_origins=CORS_ORIGINS,
158
+ allow_credentials=not DEV_MODE,
159
+ allow_methods=["*"],
160
+ allow_headers=["*"],
161
+ )
162
+
163
  app.middleware("http")(cache_middleware)
164
 
 
165
  app.include_router(health.router, prefix="/api/v1", tags=["health"])
166
  app.include_router(auth_r.router, prefix="/api/v1/auth", tags=["auth"])
167
  app.include_router(movies.router, prefix="/api/v1/movies", tags=["movies"])
 
179
  app.include_router(leaderboard.router, prefix="/api/v1/leaderboard", tags=["leaderboard"])
180
  app.include_router(comments.router, tags=["comments"])
181
  app.include_router(archive.router, prefix="/api/v1/archive", tags=["archive"])
182
+
 
 
 
 
 
183
 
184
  @app.get("/")
185
  async def root():
186
+ return {"status": "ok", "org": ORG, "version": "3.0", "dev_mode": DEV_MODE, "accounts": list(ACCOUNTS.keys())}
187
+
188
 
189
  @app.get("/health")
190
  @limiter.limit("30/minute")
191
  async def root_health(request: Request):
192
+ return {"ok": True, "data": {"status": "healthy", "version": "3.0.0", "dev_mode": DEV_MODE}}
193
+
194
+
195
+ @app.get("/status")
196
+ async def compat_status(request: Request):
197
+ from routers.archive import full_status
198
+ return await full_status(request)
199
+
200
+
201
+ @app.get("/network")
202
+ async def compat_network(request: Request):
203
+ from routers.archive import network
204
+ return await network(request)
205
+
206
+
207
+ @app.get("/archive/progress")
208
+ async def compat_archive_progress(request: Request, archive_id: str = None):
209
+ from routers.archive import handle_progress_get
210
+ return await handle_progress_get(request, archive_id)
211
+
212
+
213
+ @app.get("/archive/active")
214
+ async def compat_archive_active(request: Request):
215
+ from routers.archive import handle_archive_active
216
+ return await handle_archive_active(request)
217
+
218
+
219
+ @app.get("/archive/stats")
220
+ async def compat_archive_stats(request: Request):
221
+ from routers.archive import handle_stats
222
+ return await handle_stats(request)
223
+
224
+
225
+ @app.get("/archive/storage")
226
+ async def compat_archive_storage(request: Request):
227
+ from routers.archive import handle_storage
228
+ return await handle_storage(request)
229
+
230
+
231
+ @app.get("/orchestrate")
232
+ async def compat_orchestrate(request: Request, target: str = "all", action: str = "ping"):
233
+ from routers.archive import orchestrate
234
+ return await orchestrate(request, target, action)
235
+
236
+
237
+ @app.post("/archive/file")
238
+ async def compat_archive_file(request: Request):
239
+ from routers.archive import handle_archive_file, ArchiveFileRequest
240
+ body = await request.json()
241
+ req = ArchiveFileRequest(**body)
242
+ return await handle_archive_file(req, request)
243
+
244
+
245
+ @app.post("/archive/progress/update")
246
+ async def compat_archive_progress_update(request: Request):
247
+ from routers.archive import handle_progress_update, ProgressUpdateRequest
248
+ raw = await request.json()
249
+ aid = raw.get("archive_id", "")
250
+ if not aid:
251
+ raise HTTPException(status_code=400, detail="Missing archive_id")
252
+ req = ProgressUpdateRequest(archive_id=aid)
253
+ return await handle_progress_update(req, request)
254
+
255
+
256
+ @app.post("/archive/check-duplicate")
257
+ async def compat_archive_check_dup(request: Request):
258
+ from routers.archive import handle_check_duplicate, CheckDuplicateRequest
259
+ body = await request.json()
260
+ req = CheckDuplicateRequest(**body)
261
+ return await handle_check_duplicate(req, request)
262
+
263
 
264
  @app.exception_handler(Exception)
265
  async def global_exception_handler(request, exc):
266
+ return JSONResponse(status_code=500, content={"ok": False, "error": str(exc)[:300]})
config.py CHANGED
@@ -1,15 +1,12 @@
1
  import os
2
- from dotenv import load_dotenv
3
-
4
- load_dotenv() # Load .env file for local development
5
 
6
  ORG = "MML-ANALYSING-SHYPER-AND-API-MID-STRUCTER"
7
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
8
- TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
9
- TMDB_API_KEY = os.environ.get("TMDB_API_KEY", "")
10
  DEV_MODE = os.environ.get("DEV_MODE", "").lower() in ("true", "1", "yes")
11
 
12
- DB_SPACE_URL = os.environ.get("DB_SPACE_URL", f"https://{ORG.lower()}-db.hf.space")
13
 
14
  ACCOUNTS = {
15
  "main": ORG,
@@ -33,12 +30,19 @@ ACCOUNT_TOKENS = {
33
  "user4": os.environ.get("HF_TOKEN_USER4", ""),
34
  }
35
 
36
- PROXY_SPACE_TTL = 28800 # 8 hours
37
- PROXY_CACHE_TTL = 3600 # 1 hour
38
 
39
  TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"
40
 
41
- CORS_ORIGINS = ["*"]
 
 
 
 
 
 
 
42
 
43
 
44
  def resolve_proxy_url(account_key: str) -> str:
@@ -53,7 +57,7 @@ def resolve_archiver_url(account_key: str) -> str:
53
  return f"https://{slug}-archiver.hf.space"
54
 
55
 
56
- def dataset_for_account(account_key: str) -> str | None:
57
  namespace = ACCOUNTS.get(account_key, account_key)
58
  if namespace == ORG:
59
  return None
 
1
  import os
 
 
 
2
 
3
  ORG = "MML-ANALYSING-SHYPER-AND-API-MID-STRUCTER"
4
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
5
+ TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")
6
+ TMDB_API_KEY = os.environ.get("TMDB_API_KEY")
7
  DEV_MODE = os.environ.get("DEV_MODE", "").lower() in ("true", "1", "yes")
8
 
9
+ DB_SPACE_URL = os.environ.get("DB_SPACE_URL", f"https://{ORG.lower().replace('_', '-')}-db.hf.space")
10
 
11
  ACCOUNTS = {
12
  "main": ORG,
 
30
  "user4": os.environ.get("HF_TOKEN_USER4", ""),
31
  }
32
 
33
+ PROXY_SPACE_TTL = 28800
34
+ PROXY_CACHE_TTL = 3600
35
 
36
  TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"
37
 
38
+ CORS_ORIGINS = ["*"] if DEV_MODE else [
39
+ "https://mml-analysing-shyper-and-api-mid-structer-api.hf.space",
40
+ "https://popcornminisite.github.io",
41
+ "https://jamalmellouki.github.io",
42
+ "https://popcorn.app",
43
+ "http://localhost:5173",
44
+ "http://localhost:3000",
45
+ ]
46
 
47
 
48
  def resolve_proxy_url(account_key: str) -> str:
 
57
  return f"https://{slug}-archiver.hf.space"
58
 
59
 
60
+ def dataset_for_account(account_key: str) -> str:
61
  namespace = ACCOUNTS.get(account_key, account_key)
62
  if namespace == ORG:
63
  return None
database.py CHANGED
@@ -2,7 +2,6 @@ import json
2
  import os
3
  import aiohttp
4
  from config import DB_SPACE_URL, HF_TOKEN
5
- from typing import Any
6
 
7
  HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"}
8
  _session: aiohttp.ClientSession | None = None
@@ -22,8 +21,7 @@ async def close_session():
22
  _session = None
23
 
24
 
25
- def _escape(val: Any) -> str:
26
- """Safe value escaping for SQLite inline usage."""
27
  if val is None:
28
  return "NULL"
29
  t = type(val)
@@ -31,91 +29,62 @@ def _escape(val: Any) -> str:
31
  return str(val)
32
  if t is bool:
33
  return "1" if val else "0"
34
- s = str(val)
35
- # Instead of manual escaping, we use parameterized queries via the JSON API
36
- return json.dumps(s)
37
 
38
 
39
  def _build_sql(sql: str, params: list | None = None) -> str:
40
- """
41
- Build SQL string by replacing ? placeholders with escaped values.
42
- NOTE: This is a fallback. The prefered approach is to pass params
43
- separately when the DB supports parameterized queries.
44
- """
45
  if not params:
46
  return sql
47
  parts = sql.split("?")
48
  result = parts[0]
49
  for i, p in enumerate(params):
 
 
 
50
  if i + 1 < len(parts):
51
- result += _escape(p) + parts[i + 1]
52
  else:
53
- result += _escape(p)
54
  return result
55
 
56
 
57
  async def exec(sql: str, params: list = None) -> dict:
58
- """Execute a SQL statement (INSERT, UPDATE, DELETE, DDL)."""
59
  session = await get_session()
60
  full_sql = _build_sql(sql, params)
61
- payload: dict[str, Any] = {"sql": full_sql}
62
- async with session.post(f"{DB_SPACE_URL}/exec", json=payload) as resp:
63
- if resp.status >= 400:
64
- text = await resp.text()
65
- return {"ok": False, "error": text[:500]}
66
  return await resp.json()
67
 
68
 
69
  async def query(sql: str, params: list = None) -> dict:
70
- """Query the database and return raw response."""
71
  session = await get_session()
72
  full_sql = _build_sql(sql, params)
73
- async with session.get(
74
- f"{DB_SPACE_URL}/query",
75
- params={"sql": full_sql},
76
- ) as resp:
77
- if resp.status >= 400:
78
- text = await resp.text()
79
- return {"ok": False, "error": text[:500], "cols": [], "rows": []}
80
  return await resp.json()
81
 
82
 
83
  async def fetch_all(sql: str, params: list = None) -> list[dict]:
84
- """Fetch multiple rows as dicts."""
85
  r = await query(sql, params)
86
  if not r or "rows" not in r or not r["rows"]:
87
  return []
88
- if r.get("ok") is False:
89
- return []
90
  cols = r.get("cols") or r.get("columns", [])
91
  col_names = [c["name"] if isinstance(c, dict) else c for c in cols]
92
  return [dict(zip(col_names, row)) for row in r["rows"]]
93
 
94
 
95
  async def fetch_one(sql: str, params: list = None) -> dict | None:
96
- """Fetch a single row as a dict, or None."""
97
  rows = await fetch_all(sql, params)
98
  return rows[0] if rows else None
99
 
100
 
101
  async def execute(sql: str, params: list = None) -> dict:
102
- """Alias for exec()."""
103
  return await exec(sql, params)
104
 
105
 
106
  async def init_tables(sqls: list[str]) -> list[dict]:
107
- """Execute a list of SQL statements (for schema init)."""
108
  results = []
109
  for sql in sqls:
110
  r = await exec(sql)
111
  results.append(r)
112
  return results
113
-
114
-
115
- async def execute_many(sql: str, params_list: list[list]) -> list[dict]:
116
- """Execute the same SQL with multiple parameter sets."""
117
- results = []
118
- for params in params_list:
119
- r = await exec(sql, params)
120
- results.append(r)
121
- return results
 
2
  import os
3
  import aiohttp
4
  from config import DB_SPACE_URL, HF_TOKEN
 
5
 
6
  HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"}
7
  _session: aiohttp.ClientSession | None = None
 
21
  _session = None
22
 
23
 
24
+ def _escape(val):
 
25
  if val is None:
26
  return "NULL"
27
  t = type(val)
 
29
  return str(val)
30
  if t is bool:
31
  return "1" if val else "0"
32
+ s = str(val).replace("'", "''")
33
+ return f"'{s}'"
 
34
 
35
 
36
  def _build_sql(sql: str, params: list | None = None) -> str:
37
+ # Internal use only: builds SQL from template with ? placeholders
 
 
 
 
38
  if not params:
39
  return sql
40
  parts = sql.split("?")
41
  result = parts[0]
42
  for i, p in enumerate(params):
43
+ val = p
44
+ if not isinstance(val, (int, float, bool)):
45
+ val = str(val)
46
  if i + 1 < len(parts):
47
+ result += _escape(val) + parts[i + 1]
48
  else:
49
+ result += _escape(val)
50
  return result
51
 
52
 
53
  async def exec(sql: str, params: list = None) -> dict:
 
54
  session = await get_session()
55
  full_sql = _build_sql(sql, params)
56
+ async with session.post(f"{DB_SPACE_URL}/exec", json={"sql": full_sql}) as resp:
 
 
 
 
57
  return await resp.json()
58
 
59
 
60
  async def query(sql: str, params: list = None) -> dict:
 
61
  session = await get_session()
62
  full_sql = _build_sql(sql, params)
63
+ async with session.get(f"{DB_SPACE_URL}/query", params={"sql": full_sql}) as resp:
 
 
 
 
 
 
64
  return await resp.json()
65
 
66
 
67
  async def fetch_all(sql: str, params: list = None) -> list[dict]:
 
68
  r = await query(sql, params)
69
  if not r or "rows" not in r or not r["rows"]:
70
  return []
 
 
71
  cols = r.get("cols") or r.get("columns", [])
72
  col_names = [c["name"] if isinstance(c, dict) else c for c in cols]
73
  return [dict(zip(col_names, row)) for row in r["rows"]]
74
 
75
 
76
  async def fetch_one(sql: str, params: list = None) -> dict | None:
 
77
  rows = await fetch_all(sql, params)
78
  return rows[0] if rows else None
79
 
80
 
81
  async def execute(sql: str, params: list = None) -> dict:
 
82
  return await exec(sql, params)
83
 
84
 
85
  async def init_tables(sqls: list[str]) -> list[dict]:
 
86
  results = []
87
  for sql in sqls:
88
  r = await exec(sql)
89
  results.append(r)
90
  return results
 
 
 
 
 
 
 
 
 
db_init.py CHANGED
@@ -260,132 +260,4 @@ SEED_SQLS = [
260
  (7, 'Collector', 'Buy 5 items from the store', '\U0001f48e', 'store', 5),
261
  (8, 'High Roller', 'Earn 1000 kernels', '\U0001f4b0', 'earning', 1000)
262
  """,
263
- # ── Social Feed ──
264
- """CREATE TABLE IF NOT EXISTS social_posts (
265
- id INTEGER PRIMARY KEY AUTOINCREMENT,
266
- user_id INTEGER NOT NULL,
267
- post_type TEXT DEFAULT 'status',
268
- content TEXT,
269
- media_type TEXT,
270
- media_id INTEGER,
271
- image_url TEXT,
272
- is_spoiler INTEGER DEFAULT 0,
273
- tags TEXT,
274
- likes_count INTEGER DEFAULT 0,
275
- comments_count INTEGER DEFAULT 0,
276
- shares_count INTEGER DEFAULT 0,
277
- views_count INTEGER DEFAULT 0,
278
- status TEXT DEFAULT 'active',
279
- created_at TEXT DEFAULT (datetime('now'))
280
- )""",
281
- """CREATE TABLE IF NOT EXISTS social_likes (
282
- id INTEGER PRIMARY KEY AUTOINCREMENT,
283
- post_id INTEGER NOT NULL,
284
- user_id INTEGER NOT NULL,
285
- created_at TEXT DEFAULT (datetime('now')),
286
- UNIQUE(post_id, user_id)
287
- )""",
288
- """CREATE TABLE IF NOT EXISTS social_saves (
289
- id INTEGER PRIMARY KEY AUTOINCREMENT,
290
- post_id INTEGER NOT NULL,
291
- user_id INTEGER NOT NULL,
292
- created_at TEXT DEFAULT (datetime('now')),
293
- UNIQUE(post_id, user_id)
294
- )""",
295
- # ── Clubs ──
296
- """CREATE TABLE IF NOT EXISTS clubs (
297
- id INTEGER PRIMARY KEY AUTOINCREMENT,
298
- name TEXT NOT NULL,
299
- description TEXT,
300
- cover_image TEXT,
301
- owner_user_id INTEGER NOT NULL,
302
- is_private INTEGER DEFAULT 0,
303
- tags TEXT,
304
- weekly_movie_tmdb_id INTEGER,
305
- member_count INTEGER DEFAULT 0,
306
- rules TEXT,
307
- status TEXT DEFAULT 'active',
308
- created_at TEXT DEFAULT (datetime('now'))
309
- )""",
310
- """CREATE TABLE IF NOT EXISTS club_members (
311
- id INTEGER PRIMARY KEY AUTOINCREMENT,
312
- club_id INTEGER NOT NULL,
313
- user_id INTEGER NOT NULL,
314
- role TEXT DEFAULT 'member',
315
- joined_at TEXT DEFAULT (datetime('now')),
316
- UNIQUE(club_id, user_id)
317
- )""",
318
- # ── Gamification ──
319
- """CREATE TABLE IF NOT EXISTS daily_quests (
320
- id INTEGER PRIMARY KEY AUTOINCREMENT,
321
- title TEXT NOT NULL,
322
- description TEXT,
323
- xp_reward INTEGER DEFAULT 50,
324
- stars_reward INTEGER DEFAULT 0,
325
- kernels_reward INTEGER DEFAULT 0,
326
- icon TEXT,
327
- sort_order INTEGER DEFAULT 0,
328
- is_active INTEGER DEFAULT 1
329
- )""",
330
- """CREATE TABLE IF NOT EXISTS user_quests (
331
- id INTEGER PRIMARY KEY AUTOINCREMENT,
332
- user_id INTEGER NOT NULL,
333
- quest_id INTEGER NOT NULL,
334
- progress INTEGER DEFAULT 0,
335
- completed INTEGER DEFAULT 0,
336
- date TEXT DEFAULT (datetime('now', 'start of day')),
337
- UNIQUE(user_id, quest_id, date)
338
- )""",
339
- """CREATE TABLE IF NOT EXISTS weekly_challenges (
340
- id INTEGER PRIMARY KEY AUTOINCREMENT,
341
- title TEXT NOT NULL,
342
- description TEXT,
343
- xp_reward INTEGER DEFAULT 200,
344
- stars_reward INTEGER DEFAULT 50,
345
- kernels_reward INTEGER DEFAULT 10,
346
- icon TEXT,
347
- is_active INTEGER DEFAULT 1,
348
- starts_at TEXT,
349
- ends_at TEXT
350
- )""",
351
- """CREATE TABLE IF NOT EXISTS season_pass_levels (
352
- id INTEGER PRIMARY KEY AUTOINCREMENT,
353
- level_num INTEGER NOT NULL UNIQUE,
354
- xp_required INTEGER NOT NULL,
355
- free_reward TEXT,
356
- premium_reward TEXT
357
- )""",
358
- """CREATE TABLE IF NOT EXISTS user_season_pass (
359
- id INTEGER PRIMARY KEY AUTOINCREMENT,
360
- user_id INTEGER NOT NULL,
361
- level_num INTEGER NOT NULL,
362
- claimed INTEGER DEFAULT 0,
363
- claimed_at TEXT,
364
- UNIQUE(user_id, level_num)
365
- )""",
366
- # ── Seed Data ──
367
- """INSERT OR IGNORE INTO daily_quests (id, title, description, xp_reward, icon, sort_order) VALUES
368
- (1, 'شاهد فيلماً', 'شاهد أي فيلم اليوم', 50, '\U0001f3ac', 1),
369
- (2, 'قيم 3 أفلام', 'أضف تقييمك لثلاثة أفلام', 30, '\u2b50', 2),
370
- (3, 'شارك في حفلة', 'انضم أو أنشئ حفلة مشاهدة', 60, '\U0001f465', 3),
371
- (4, 'اكتب مراجعة', 'شارك رأيك بفيلم شاهدته', 40, '\U0001f4dd', 4),
372
- (5, 'تصفح 10 دقائق', 'تصفح التطبيق لمدة 10 دقائق', 20, '\u23f1\ufe0f', 5)
373
- """,
374
- """INSERT OR IGNORE INTO weekly_challenges (id, title, description, xp_reward, stars_reward, icon) VALUES
375
- (1, 'ماراثون أفلام', 'شاهد 5 أفلام هذا الأسبوع', 200, 50, '\U0001f3c3'),
376
- (2, 'مستكشف الأنواع', 'جرب 3 أنواع مختلفة من الأفلام', 150, 30, '\U0001f9ed'),
377
- (3, 'اجتماعي', 'شارك في 3 حفلات مشاهدة', 180, 40, '\U0001f91d')
378
- """,
379
- """INSERT OR IGNORE INTO season_pass_levels (level_num, xp_required, free_reward, premium_reward) VALUES
380
- (1, 0, '10 Stars', '20 Stars'),
381
- (2, 100, '5 Kernels', '15 Kernels'),
382
- (3, 250, '20 Stars', '🎨 Asset Common'),
383
- (4, 500, '10 Kernels', '30 Stars'),
384
- (5, 800, '50 Stars', '🎨 Asset Rare'),
385
- (6, 1200, '15 Kernels', '100 Stars'),
386
- (7, 1800, '100 Stars', '👑 Premium 1 Month'),
387
- (8, 2500, '30 Kernels', '🎨 Asset Epic'),
388
- (9, 4000, '200 Stars', '💎 500 Kernels'),
389
- (10, 6000, '🎨 Asset Legendary', '👑 Premium 3 Months')
390
- """,
391
  ]
 
260
  (7, 'Collector', 'Buy 5 items from the store', '\U0001f48e', 'store', 5),
261
  (8, 'High Roller', 'Earn 1000 kernels', '\U0001f4b0', 'earning', 1000)
262
  """,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  ]
requirements.txt CHANGED
@@ -4,5 +4,3 @@ aiohttp>=3.9
4
  python-dotenv>=1.0
5
  python-multipart>=0.0.20
6
  slowapi>=0.1.9
7
- pydantic>=2.0
8
- python-dotenv>=1.0
 
4
  python-dotenv>=1.0
5
  python-multipart>=0.0.20
6
  slowapi>=0.1.9
 
 
routers/analytics.py DELETED
@@ -1,79 +0,0 @@
1
- """Analytics API - إحصائيات وتحليلات المستخدم"""
2
- from fastapi import APIRouter, HTTPException, Request
3
- from database import fetch_one, fetch_all
4
- from auth import get_current_user
5
-
6
- router = APIRouter()
7
-
8
- async def _resolve_user(request: Request) -> dict:
9
- user_data = await get_current_user(request)
10
- user = await fetch_one("SELECT * FROM users WHERE user_id = ?", [str(user_data["id"])])
11
- if not user:
12
- raise HTTPException(status_code=404, detail="User not found")
13
- return user
14
-
15
- @router.get("/stats")
16
- async def get_user_stats(request: Request):
17
- """Get comprehensive user statistics"""
18
- user = await _resolve_user(request)
19
- db_id = str(user["id"])
20
-
21
- total_movies = await fetch_one("SELECT COUNT(*) as cnt FROM watch_history WHERE user_id=? AND media_type='movie'", [db_id])
22
- total_series = await fetch_one("SELECT COUNT(DISTINCT tmdb_id) as cnt FROM watch_history WHERE user_id=? AND media_type='tv'", [db_id])
23
- total_watch_time = await fetch_one("SELECT COALESCE(SUM(duration_seconds), 0) as total FROM watch_history WHERE user_id=?", [db_id])
24
- avg_rating = await fetch_one("SELECT COALESCE(AVG(rating), 0) as avg FROM ratings WHERE user_id=?", [db_id])
25
- total_reviews = await fetch_one("SELECT COUNT(*) as cnt FROM comments WHERE user_id=?", [db_id])
26
- achievements = await fetch_one("SELECT COUNT(*) as cnt FROM user_achievements WHERE user_id=? AND is_unlocked=1", [db_id])
27
- streak = await fetch_one("SELECT streak_count FROM daily_streaks WHERE user_id=?", [db_id])
28
-
29
- return {"ok": True, "data": {
30
- "total_movies": total_movies["cnt"] if total_movies else 0,
31
- "total_series": total_series["cnt"] if total_series else 0,
32
- "total_watch_time": total_watch_time["total"] if total_watch_time else 0,
33
- "average_rating": round(float(avg_rating["avg"] if avg_rating else 0), 1),
34
- "total_reviews": total_reviews["cnt"] if total_reviews else 0,
35
- "achievements_unlocked": achievements["cnt"] if achievements else 0,
36
- "current_streak": streak["streak_count"] if streak else 0,
37
- }}
38
-
39
- @router.get("/stats/genres")
40
- async def get_genre_breakdown(request: Request):
41
- """Get genre distribution from watch history"""
42
- user = await _resolve_user(request)
43
- rows = await fetch_all("""
44
- SELECT a.genres, COUNT(*) as count FROM watch_history wh
45
- JOIN archives a ON wh.tmdb_id = a.media_id
46
- WHERE wh.user_id=? AND a.genres != ''
47
- GROUP BY a.genres ORDER BY count DESC""", [str(user["id"])])
48
-
49
- genre_counts = {}
50
- for r in rows:
51
- for g in (r["genres"] or "").split(","):
52
- g = g.strip()
53
- if g:
54
- genre_counts[g] = genre_counts.get(g, 0) + r["count"]
55
- total = sum(genre_counts.values()) or 1
56
- data = [{"genre": k, "count": v, "percentage": round(v / total * 100, 1)} for k, v in sorted(genre_counts.items(), key=lambda x: -x[1])]
57
- return {"ok": True, "data": data}
58
-
59
- @router.get("/stats/activity")
60
- async def get_activity_history(request: Request):
61
- """Get recent user activity"""
62
- user = await _resolve_user(request)
63
- activities = []
64
- wh = await fetch_all(
65
- "SELECT tmdb_id, media_type, last_watched_at FROM watch_history WHERE user_id=? ORDER BY last_watched_at DESC LIMIT 10",
66
- [str(user["id"])]
67
- )
68
- for w in wh:
69
- activities.append({"action": "شاهد", "target": f"#{w['tmdb_id']}", "time": w.get("last_watched_at", ""), "icon": "🎬"})
70
-
71
- ratings = await fetch_all(
72
- "SELECT tmdb_id, rating, updated_at FROM ratings WHERE user_id=? ORDER BY updated_at DESC LIMIT 5",
73
- [str(user["id"])]
74
- )
75
- for r in ratings:
76
- activities.append({"action": "قيم", "target": f"#{r['tmdb_id']} بـ {r['rating']}/10", "time": r.get("updated_at", ""), "icon": "⭐"})
77
-
78
- activities.sort(key=lambda x: x["time"], reverse=True)
79
- return {"ok": True, "data": activities[:15]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
routers/clubs.py DELETED
@@ -1,113 +0,0 @@
1
- """Movie Clubs API - الأندية السينمائية"""
2
- from fastapi import APIRouter, HTTPException, Request, Query
3
- from pydantic import BaseModel
4
- from typing import Optional
5
- from database import fetch_one, fetch_all, execute
6
- from auth import get_current_user
7
- from routers.utils import create_notification
8
-
9
- router = APIRouter()
10
-
11
- async def _resolve_user(request: Request) -> dict:
12
- user_data = await get_current_user(request)
13
- user = await fetch_one("SELECT * FROM users WHERE user_id = ?", [str(user_data["id"])])
14
- if not user:
15
- raise HTTPException(status_code=404, detail="User not found")
16
- return user
17
-
18
- class CreateClub(BaseModel):
19
- name: str
20
- description: str
21
- is_private: bool = False
22
- tags: Optional[list[str]] = None
23
-
24
- class UpdateClub(BaseModel):
25
- name: Optional[str] = None
26
- description: Optional[str] = None
27
- weekly_movie_tmdb_id: Optional[int] = None
28
-
29
- @router.get("/clubs")
30
- async def list_clubs(page: int = Query(1, ge=1), per_page: int = Query(20), search: str = "", tag: str = ""):
31
- """List public clubs with optional search/filter"""
32
- where = "WHERE c.is_private=0"
33
- params = []
34
- if search:
35
- where += " AND (c.name LIKE ? OR c.description LIKE ?)"
36
- params.extend([f"%{search}%", f"%{search}%"])
37
- if tag:
38
- where += " AND c.tags LIKE ?"
39
- params.append(f"%{tag}%")
40
-
41
- offset = (page - 1) * per_page
42
- total = await fetch_one(f"SELECT COUNT(*) as cnt FROM clubs c {where}", params)
43
- clubs = await fetch_all(f"""
44
- SELECT c.*, u.first_name as owner_name,
45
- (SELECT COUNT(*) FROM club_members WHERE club_id=c.id) as member_count
46
- FROM clubs c LEFT JOIN users u ON c.owner_user_id = u.id
47
- {where} ORDER BY c.member_count DESC LIMIT {per_page} OFFSET {offset}""", params)
48
- return {"ok": True, "data": clubs, "meta": {"total": total["cnt"] if total else 0, "page": page}}
49
-
50
- @router.post("/clubs")
51
- async def create_club(body: CreateClub, request: Request):
52
- """Create a new movie club"""
53
- user = await _resolve_user(request)
54
- tags_str = ",".join(body.tags) if body.tags else ""
55
- await execute(
56
- """INSERT INTO clubs (name, description, owner_user_id, is_private, tags, member_count, created_at)
57
- VALUES (?, ?, ?, ?, ?, 1, datetime('now'))""",
58
- [body.name, body.description, str(user["user_id"]), "1" if body.is_private else "0", tags_str]
59
- )
60
- club = await fetch_one("SELECT * FROM clubs WHERE name=? AND owner_user_id=?", [body.name, str(user["user_id"])])
61
- if club:
62
- await execute("INSERT INTO club_members (club_id, user_id, role) VALUES (?, ?, 'owner')", [str(club["id"]), str(user["user_id"])])
63
- return {"ok": True, "data": {"created": True}}
64
-
65
- @router.get("/clubs/my")
66
- async def get_my_clubs(request: Request):
67
- """Get clubs the user belongs to"""
68
- user = await _resolve_user(request)
69
- clubs = await fetch_all(
70
- """SELECT c.*, cm.role, (SELECT COUNT(*) FROM club_members WHERE club_id=c.id) as member_count
71
- FROM clubs c JOIN club_members cm ON c.id=cm.club_id
72
- WHERE cm.user_id=? ORDER BY cm.joined_at DESC""", [str(user["user_id"])]
73
- )
74
- return {"ok": True, "data": clubs}
75
-
76
- @router.get("/clubs/{club_id}")
77
- async def get_club(club_id: int, request: Request):
78
- """Get club details"""
79
- user = await _resolve_user(request)
80
- club = await fetch_one("SELECT * FROM clubs WHERE id=?", [str(club_id)])
81
- if not club:
82
- raise HTTPException(status_code=404, detail="Club not found")
83
- members = await fetch_all(
84
- "SELECT cm.*, u.first_name, u.username, u.photo_url FROM club_members cm LEFT JOIN users u ON cm.user_id=u.user_id WHERE cm.club_id=?",
85
- [str(club_id)]
86
- )
87
- return {"ok": True, "data": {**club, "members": members}}
88
-
89
- @router.post("/clubs/{club_id}/join")
90
- async def join_club(club_id: int, request: Request):
91
- """Join a public club"""
92
- user = await _resolve_user(request)
93
- club = await fetch_one("SELECT * FROM clubs WHERE id=?", [str(club_id)])
94
- if not club:
95
- raise HTTPException(status_code=404, detail="Club not found")
96
- existing = await fetch_one("SELECT * FROM club_members WHERE club_id=? AND user_id=?", [str(club_id), str(user["user_id"])])
97
- if existing:
98
- return {"ok": True, "data": {"message": "Already a member"}}
99
-
100
- await execute("INSERT INTO club_members (club_id, user_id, role) VALUES (?, ?, 'member')", [str(club_id), str(user["user_id"])])
101
- await execute("UPDATE clubs SET member_count = member_count + 1 WHERE id=?", [str(club_id)])
102
-
103
- # Notify club owner
104
- await create_notification(club["owner_user_id"], "club_join", f"انضم عضو جديد", f"انضم {user.get('first_name', '')} إلى نادي {club['name']}")
105
- return {"ok": True, "data": {"joined": True}}
106
-
107
- @router.post("/clubs/{club_id}/leave")
108
- async def leave_club(club_id: int, request: Request):
109
- """Leave a club"""
110
- user = await _resolve_user(request)
111
- await execute("DELETE FROM club_members WHERE club_id=? AND user_id=?", [str(club_id), str(user["user_id"])])
112
- await execute("UPDATE clubs SET member_count = MAX(0, member_count - 1) WHERE id=?", [str(club_id)])
113
- return {"ok": True, "data": {"left": True}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
routers/gamification.py DELETED
@@ -1,93 +0,0 @@
1
- """Gamification API - التحديات والإنجازات ونظام المستويات"""
2
- from fastapi import APIRouter, HTTPException, Request, Query
3
- from database import fetch_one, fetch_all, execute
4
- from auth import get_current_user
5
- from routers.utils import progress_achievement
6
-
7
- router = APIRouter()
8
-
9
- LEVELS = [
10
- (1, "New Popper 🍿", 0), (2, "Movie Fan 🎬", 100), (3, "Film Buff ⭐", 300),
11
- (4, "Cinema Lover 🎥", 600), (5, "Movie Star 🌟", 1000), (6, "Film Critic 🏆", 1500),
12
- (7, "Cinema King 👑", 2500), (8, "Movie Legend 🎪", 4000), (9, "Hall of Fame ⭐", 6000),
13
- (10, "Living Cinema 🎬", 10000),
14
- ]
15
-
16
- async def _resolve_user(request: Request) -> dict:
17
- user_data = await get_current_user(request)
18
- user = await fetch_one("SELECT * FROM users WHERE user_id = ?", [str(user_data["id"])])
19
- if not user:
20
- raise HTTPException(status_code=404, detail="User not found")
21
- return user
22
-
23
- @router.get("/xp")
24
- async def get_user_xp(request: Request):
25
- """Get user level, XP, and progress to next level"""
26
- user = await _resolve_user(request)
27
- xp = user.get("xp", 0) or 0
28
- current_level = 1
29
- for level_num, title, xp_req in LEVELS:
30
- if xp >= xp_req:
31
- current_level = level_num
32
- next_xp = LEVELS[min(current_level, len(LEVELS)-1)][2] if current_level < len(LEVELS) else LEVELS[-1][2]
33
- progress = min(100, int((xp / next_xp) * 100)) if next_xp else 100
34
- return {
35
- "ok": True, "data": {
36
- "xp": xp, "level": current_level,
37
- "title": LEVELS[current_level-1][1] if current_level <= len(LEVELS) else "Legend",
38
- "xp_to_next": next_xp - xp, "progress": progress
39
- }
40
- }
41
-
42
- @router.get("/quests/daily")
43
- async def get_daily_quests(request: Request):
44
- """Get today's daily quests"""
45
- user = await _resolve_user(request)
46
- today = "datetime('now', 'start of day')"
47
- quests = await fetch_all(f"""
48
- SELECT dq.*, COALESCE(uq.progress, 0) as user_progress,
49
- CASE WHEN uq.completed=1 THEN 1 ELSE 0 END as completed
50
- FROM daily_quests dq
51
- LEFT JOIN user_quests uq ON dq.id=uq.quest_id AND uq.user_id=? AND uq.date={today}
52
- WHERE dq.is_active=1 ORDER BY dq.sort_order""", [str(user["id"])])
53
- return {"ok": True, "data": quests}
54
-
55
- @router.get("/quests/weekly")
56
- async def get_weekly_challenges(request: Request):
57
- """Get weekly challenges"""
58
- user = await _resolve_user(request)
59
- challenges = await fetch_all("""
60
- SELECT wc.*, COALESCE(uw.progress, 0) as user_progress,
61
- CASE WHEN uw.completed=1 THEN 1 ELSE 0 END as completed
62
- FROM weekly_challenges wc
63
- LEFT JOIN user_weekly uw ON wc.id=uw.challenge_id AND uw.user_id=?
64
- WHERE wc.is_active=1""", [str(user["id"])])
65
- return {"ok": True, "data": challenges}
66
-
67
- @router.post("/quests/{quest_id}/progress")
68
- async def update_quest_progress(quest_id: int, request: Request):
69
- """Update progress for a quest/challenge"""
70
- user = await _resolve_user(request)
71
- await execute("""
72
- INSERT INTO user_quests (user_id, quest_id, progress, date)
73
- VALUES (?, ?, 1, datetime('now', 'start of day'))
74
- ON CONFLICT(user_id, quest_id, date) DO UPDATE SET progress=progress+1""",
75
- [str(user["id"]), str(quest_id)])
76
- return {"ok": True, "data": {"progressed": True}}
77
-
78
- @router.get("/season-pass")
79
- async def get_season_pass(request: Request):
80
- """Get season pass levels and rewards"""
81
- user = await _resolve_user(request)
82
- levels = await fetch_all("SELECT * FROM season_pass_levels ORDER BY level_num")
83
- user_levels = await fetch_all("SELECT * FROM user_season_pass WHERE user_id=?", [str(user["id"])])
84
- claimed = {ul["level_num"]: ul["claimed"] for ul in user_levels}
85
- result = []
86
- for lv in levels:
87
- result.append({
88
- "level": lv["level_num"], "xp_required": lv["xp_required"],
89
- "free_reward": lv["free_reward"], "premium_reward": lv["premium_reward"],
90
- "claimed": claimed.get(lv["level_num"], False),
91
- "unlocked": (user.get("xp", 0) or 0) >= lv["xp_required"],
92
- })
93
- return {"ok": True, "data": result}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
routers/media.py DELETED
@@ -1,83 +0,0 @@
1
- """Media Metadata API - جلب الميتاداتا الكاملة والصور"""
2
- from fastapi import APIRouter, HTTPException, Query
3
- from database import fetch_one, fetch_all
4
-
5
- router = APIRouter()
6
-
7
- @router.get("/metadata/{tmdb_id}")
8
- async def get_media_metadata(tmdb_id: int):
9
- """Get full media metadata with credits, images, videos"""
10
- row = await fetch_one("SELECT * FROM media_metadata WHERE tmdb_id=?", [str(tmdb_id)])
11
- if not row:
12
- # Fallback to archives
13
- row = await fetch_one("SELECT * FROM archives WHERE media_id=?", [str(tmdb_id)])
14
- if not row:
15
- raise HTTPException(status_code=404, detail="Not found")
16
- return {"ok": True, "data": {"tmdb_id": tmdb_id, "title": row.get("title"), "poster_url": row.get("poster_path"), "overview": row.get("overview")}}
17
-
18
- import json
19
- cast = []
20
- crew = []
21
- try:
22
- credits = json.loads(row.get("credits_json", "{}"))
23
- cast = credits.get("cast", [])[:20]
24
- crew = credits.get("crew", [])[:10]
25
- except: pass
26
-
27
- return {
28
- "ok": True, "data": {
29
- "tmdb_id": row["tmdb_id"],
30
- "media_type": row.get("media_type"),
31
- "title": row.get("title"),
32
- "overview": row.get("overview"),
33
- "poster_url": f"https://image.tmdb.org/t/p/w500{row['poster_path']}" if row.get("poster_path") else None,
34
- "backdrop_url": f"https://image.tmdb.org/t/p/w1280{row['backdrop_path']}" if row.get("backdrop_path") else None,
35
- "year": row.get("year"),
36
- "rating": row.get("rating"),
37
- "genres": json.loads(row.get("genres", "[]")),
38
- "runtime": row.get("runtime"),
39
- "tagline": row.get("tagline"),
40
- "status": row.get("status"),
41
- "imdb_id": row.get("imdb_id"),
42
- "budget": row.get("budget"),
43
- "revenue": row.get("revenue"),
44
- "cast": cast,
45
- "crew": crew,
46
- }
47
- }
48
-
49
- @router.get("/metadata/search")
50
- async def search_metadata(q: str = Query(min_length=1)):
51
- """Search metadata"""
52
- rows = await fetch_all(
53
- "SELECT tmdb_id, title, year, rating, poster_path FROM media_metadata WHERE title LIKE ? ORDER BY popularity DESC LIMIT 20",
54
- [f"%{q}%"]
55
- )
56
- from config import TMDB_IMAGE_BASE
57
- result = []
58
- for r in rows:
59
- result.append({
60
- "tmdb_id": r["tmdb_id"],
61
- "title": r.get("title"),
62
- "year": r.get("year"),
63
- "rating": r.get("rating"),
64
- "poster_url": f"{TMDB_IMAGE_BASE}/w500{r['poster_path']}" if r.get("poster_path") else None,
65
- })
66
- return {"ok": True, "data": result}
67
-
68
- @router.get("/people/{person_id}")
69
- async def get_person(person_id: int):
70
- """Get person details"""
71
- row = await fetch_one("SELECT * FROM people WHERE id=?", [str(person_id)])
72
- if not row:
73
- raise HTTPException(status_code=404, detail="Not found")
74
- return {"ok": True, "data": {
75
- "id": row["id"],
76
- "name": row.get("name"),
77
- "known_for": row.get("known_for_department"),
78
- "profile_url": f"https://image.tmdb.org/t/p/w185{row['profile_path']}" if row.get("profile_path") else None,
79
- "popularity": row.get("popularity"),
80
- "biography": row.get("biography"),
81
- "birthday": row.get("birthday"),
82
- "deathday": row.get("deathday"),
83
- }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
routers/parties.py CHANGED
@@ -9,8 +9,6 @@ from typing import Optional
9
  from database import fetch_one, fetch_all, execute
10
  from auth import get_current_user, verify_init_data
11
  from routers.utils import progress_achievement, create_notification
12
- from ratelimit import limiter
13
- import urllib.parse
14
 
15
  router = APIRouter()
16
 
@@ -44,7 +42,7 @@ class JoinParty(BaseModel):
44
 
45
 
46
  @router.post("/create")
47
- @limiter.limit("10/minute")
48
  async def create_party(body: CreateParty, request: Request):
49
  user = await _resolve_user(request)
50
  room_code = _gen_room_code()
@@ -86,7 +84,7 @@ async def create_party(body: CreateParty, request: Request):
86
  if party_count:
87
  await progress_achievement(user["user_id"], "social", party_count[0]["cnt"])
88
 
89
- share_link = f"https://t.me/share/url?url={urllib.parse.quote(f'انضم لمشاهدة {body.title}! room_code={room_code}')}"
90
 
91
  return {
92
  "ok": True,
 
9
  from database import fetch_one, fetch_all, execute
10
  from auth import get_current_user, verify_init_data
11
  from routers.utils import progress_achievement, create_notification
 
 
12
 
13
  router = APIRouter()
14
 
 
42
 
43
 
44
  @router.post("/create")
45
+ @__import__('ratelimit').limiter.limit("10/minute")
46
  async def create_party(body: CreateParty, request: Request):
47
  user = await _resolve_user(request)
48
  room_code = _gen_room_code()
 
84
  if party_count:
85
  await progress_achievement(user["user_id"], "social", party_count[0]["cnt"])
86
 
87
+ share_link = f"https://t.me/share/url?url={__import__('urllib').parse.quote(f'انضم لمشاهدة {body.title}! room_code={room_code}')}"
88
 
89
  return {
90
  "ok": True,
routers/payments.py CHANGED
@@ -1,18 +1,12 @@
1
  from fastapi import APIRouter, HTTPException, Request
2
  from pydantic import BaseModel
3
  from typing import Optional
4
- from database import fetch_one, fetch_all, execute
5
  from auth import get_current_user, optional_current_user
6
  from routers.utils import progress_achievement, create_notification
7
- from config import TELEGRAM_BOT_TOKEN
8
- from ratelimit import limiter
9
- import os
10
 
11
  router = APIRouter()
12
 
13
- TELEGRAM_BOT_USERNAME = TELEGRAM_BOT_TOKEN.split(':')[0] if TELEGRAM_BOT_TOKEN else "PopCornMinibot"
14
- WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "")
15
-
16
 
17
  async def _resolve_user(request: Request) -> dict:
18
  user_data = await get_current_user(request)
@@ -31,8 +25,9 @@ class CreateInvoice(BaseModel):
31
 
32
  class HybridPurchase(BaseModel):
33
  product_id: int
34
- currency: str = "stars"
35
  idempotency_key: str
 
36
 
37
 
38
  class WebhookPayload(BaseModel):
@@ -45,117 +40,72 @@ class WebhookPayload(BaseModel):
45
 
46
  @router.post("/create-invoice")
47
  async def create_invoice(body: CreateInvoice, request: Request):
48
- """Create a Telegram Stars invoice for a product."""
49
  user = await _resolve_user(request)
50
  product = await fetch_one("SELECT * FROM products WHERE id=?", [str(body.product_id)])
51
  if not product:
52
  raise HTTPException(status_code=404, detail="Product not found")
53
 
54
  cost_stars = body.amount or product.get("price_stars", 0)
 
55
  payload = f"pc_{user['id']}_{body.product_id}_{body.idempotency_key}"
56
- invoice_link = f"https://t.me/{TELEGRAM_BOT_USERNAME}/invoice/{payload}"
57
 
58
  return {
59
  "ok": True,
60
  "data": {
61
  "invoice_link": invoice_link,
62
  "payload": payload,
63
- "amount": cost_stars,
64
  "currency": body.currency,
65
- "product_id": body.product_id,
66
  },
67
  }
68
 
69
 
70
  @router.post("/hybrid-purchase")
71
- @limiter.limit("10/minute")
72
  async def hybrid_purchase(body: HybridPurchase, request: Request):
73
- """Purchase a product using kernels (in-app currency) or via Telegram Stars."""
74
  user = await _resolve_user(request)
75
  product = await fetch_one("SELECT * FROM products WHERE id=?", [str(body.product_id)])
76
  if not product:
77
  raise HTTPException(status_code=404, detail="Product not found")
78
 
79
- cost_stars = product.get("price_stars", 0)
80
- cost_kernels = product.get("price_kernels", cost_stars)
81
-
82
- # Handle kernels purchase
83
- if body.currency == "kernels":
84
- cost = cost_kernels
85
  balance = user.get("kernels_balance", 0)
86
  if balance < cost:
87
  raise HTTPException(status_code=400, detail="Not enough kernels")
88
- await execute(
89
- "UPDATE users SET kernels_balance = kernels_balance - ?, total_spent_kernels = total_spent_kernels + ? WHERE id=?",
90
- [str(cost), str(cost), str(user["id"])],
91
- )
92
  else:
93
- # Stars purchase via invoice
94
- cost = cost_stars
95
  balance = user.get("stars_balance", 0)
96
- if balance >= cost:
97
- # Direct balance deduction (for dev/fallback)
98
- await execute(
99
- "UPDATE users SET stars_balance = stars_balance - ? WHERE id=?",
100
- [str(cost), str(user["id"])],
101
- )
102
- else:
103
- # Generate invoice link for Telegram Stars
104
- payload = f"pc_{user['id']}_{body.product_id}_{body.idempotency_key}"
105
- invoice_link = f"https://t.me/{TELEGRAM_BOT_USERNAME}/invoice/{payload}"
106
- return {
107
- "ok": True,
108
- "data": {
109
- "invoice_link": invoice_link,
110
- "payload": payload,
111
- "product_id": body.product_id,
112
- "currency": body.currency,
113
- "amount": cost,
114
- },
115
- }
116
-
117
- # Grant assets
118
- asset_rows = await fetch_all(
119
- "SELECT * FROM asset_items WHERE product_id=?",
120
- [str(body.product_id)],
121
- )
122
- for asset in asset_rows:
123
- await execute(
124
- """INSERT OR IGNORE INTO user_assets (user_id, product_id, asset_item_id, asset_id, is_active, acquired_at)
125
- VALUES (?, ?, ?, ?, ?, datetime('now'))""",
126
- [str(user["id"]), str(body.product_id), str(asset["id"]), str(asset["id"]), "0" if len(asset_rows) > 1 else "1"],
127
- )
128
-
129
- # Record purchase
130
  await execute(
131
- "INSERT INTO purchases (user_id, product_id, stars_amount, kernels_amount, status, purchased_at) VALUES (?, ?, ?, ?, 'completed', datetime('now'))",
132
- [str(user["id"]), str(body.product_id), str(cost_stars), str(cost_kernels)],
133
  )
134
 
135
- # Progress achievements
136
- purchase_count = await fetch_all(
137
- "SELECT COUNT(*) as cnt FROM purchases WHERE user_id=?",
138
- [str(user["id"])],
139
  )
 
 
140
  if purchase_count:
141
- await progress_achievement(user["id"], "store", purchase_count[0]["cnt"])
142
 
143
- return {
144
- "ok": True,
145
- "data": {
146
- "purchased": True,
147
- "product_id": body.product_id,
148
- "currency": body.currency,
149
- "amount": cost,
150
- },
151
- }
152
 
153
 
154
  @router.post("/webhook")
155
  async def payment_webhook(body: WebhookPayload, request: Request):
156
- """Webhook for Telegram Stars payment confirmation."""
157
  auth_header = request.headers.get("Authorization", "")
158
- if WEBHOOK_SECRET and auth_header != f"Bearer {WEBHOOK_SECRET}":
 
159
  raise HTTPException(status_code=401, detail="Invalid webhook secret")
160
 
161
  if not body.product_id or not body.user_id:
@@ -167,34 +117,13 @@ async def payment_webhook(body: WebhookPayload, request: Request):
167
 
168
  cost = product.get("price_stars", 0)
169
 
170
- # Grant assets to user
171
- asset_rows = await fetch_all(
172
- "SELECT * FROM asset_items WHERE product_id=?",
173
- [str(body.product_id)],
174
- )
175
- for asset in asset_rows:
176
- await execute(
177
- """INSERT OR IGNORE INTO user_assets (user_id, product_id, asset_item_id, asset_id, is_active, acquired_at)
178
- VALUES (?, ?, ?, ?, ?, datetime('now'))""",
179
- [str(body.user_id), str(body.product_id), str(asset["id"]), str(asset["id"]), "0"],
180
- )
181
-
182
- # Record purchase
183
  await execute(
184
- "INSERT INTO purchases (user_id, product_id, stars_amount, status, purchased_at, telegram_payment_id) VALUES (?, ?, ?, 'completed', datetime('now'), ?)",
185
- [str(body.user_id), str(body.product_id), str(cost), body.telegram_payment_id or ""],
186
  )
187
-
188
- # Send notification
189
- await create_notification(
190
- body.user_id,
191
- "purchase_completed",
192
- "Purchase Complete!",
193
- f"Thank you for your purchase of {product.get('name_en', 'item')}!",
194
- '{"product_id": ' + str(body.product_id) + '}',
195
  )
196
 
197
- return {
198
- "ok": True,
199
- "data": {"processed": True, "product_id": body.product_id, "user_id": body.user_id},
200
- }
 
1
  from fastapi import APIRouter, HTTPException, Request
2
  from pydantic import BaseModel
3
  from typing import Optional
4
+ from database import fetch_one, execute
5
  from auth import get_current_user, optional_current_user
6
  from routers.utils import progress_achievement, create_notification
 
 
 
7
 
8
  router = APIRouter()
9
 
 
 
 
10
 
11
  async def _resolve_user(request: Request) -> dict:
12
  user_data = await get_current_user(request)
 
25
 
26
  class HybridPurchase(BaseModel):
27
  product_id: int
28
+ currency: str = "XTR"
29
  idempotency_key: str
30
+ payment_method: str = "stars"
31
 
32
 
33
  class WebhookPayload(BaseModel):
 
40
 
41
  @router.post("/create-invoice")
42
  async def create_invoice(body: CreateInvoice, request: Request):
 
43
  user = await _resolve_user(request)
44
  product = await fetch_one("SELECT * FROM products WHERE id=?", [str(body.product_id)])
45
  if not product:
46
  raise HTTPException(status_code=404, detail="Product not found")
47
 
48
  cost_stars = body.amount or product.get("price_stars", 0)
49
+
50
  payload = f"pc_{user['id']}_{body.product_id}_{body.idempotency_key}"
51
+ invoice_link = f"https://t.me/{__import__('config').TELEGRAM_BOT_TOKEN.split(':')[0]}/invoice/{payload}"
52
 
53
  return {
54
  "ok": True,
55
  "data": {
56
  "invoice_link": invoice_link,
57
  "payload": payload,
58
+ "amount": body.amount,
59
  "currency": body.currency,
 
60
  },
61
  }
62
 
63
 
64
  @router.post("/hybrid-purchase")
65
+ @__import__('ratelimit').limiter.limit("10/minute")
66
  async def hybrid_purchase(body: HybridPurchase, request: Request):
 
67
  user = await _resolve_user(request)
68
  product = await fetch_one("SELECT * FROM products WHERE id=?", [str(body.product_id)])
69
  if not product:
70
  raise HTTPException(status_code=404, detail="Product not found")
71
 
72
+ if body.payment_method == "kernels":
73
+ cost = product.get("price_kernels", 0)
 
 
 
 
74
  balance = user.get("kernels_balance", 0)
75
  if balance < cost:
76
  raise HTTPException(status_code=400, detail="Not enough kernels")
77
+ await execute("UPDATE users SET kernels_balance = kernels_balance - ?, total_spent_kernels = total_spent_kernels + ? WHERE id=?",
78
+ [str(cost), str(cost), str(user["id"])])
 
 
79
  else:
80
+ cost = product.get("price_stars", 0)
 
81
  balance = user.get("stars_balance", 0)
82
+ if balance < cost:
83
+ raise HTTPException(status_code=400, detail="Not enough stars")
84
+ await execute("UPDATE users SET stars_balance = stars_balance - ? WHERE id=?",
85
+ [str(cost), str(user["id"])])
86
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  await execute(
88
+ "INSERT OR IGNORE INTO user_assets (user_id, asset_type, asset_id) VALUES (?, ?, ?)",
89
+ [str(user["id"]), "product", str(body.product_id)],
90
  )
91
 
92
+ await execute(
93
+ "INSERT INTO purchases (user_id, product_id, stars_amount, status) VALUES (?, ?, ?, 'completed')",
94
+ [str(user["id"]), str(body.product_id), str(cost)],
 
95
  )
96
+
97
+ purchase_count = await fetch_all("SELECT COUNT(*) as cnt FROM purchases WHERE user_id=?", [str(user["id"])])
98
  if purchase_count:
99
+ await progress_achievement(user["user_id"], "store", purchase_count[0]["cnt"])
100
 
101
+ return {"ok": True, "data": {"purchased": True, "product_id": body.product_id, "idempotency_key": body.idempotency_key}}
 
 
 
 
 
 
 
 
102
 
103
 
104
  @router.post("/webhook")
105
  async def payment_webhook(body: WebhookPayload, request: Request):
 
106
  auth_header = request.headers.get("Authorization", "")
107
+ token = __import__('os').environ.get("WEBHOOK_SECRET", "")
108
+ if token and auth_header != f"Bearer {token}":
109
  raise HTTPException(status_code=401, detail="Invalid webhook secret")
110
 
111
  if not body.product_id or not body.user_id:
 
117
 
118
  cost = product.get("price_stars", 0)
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  await execute(
121
+ "INSERT OR IGNORE INTO user_assets (user_id, asset_type, asset_id) VALUES (?, ?, ?)",
122
+ [str(body.user_id), "product", str(body.product_id)],
123
  )
124
+ await execute(
125
+ "INSERT INTO purchases (user_id, product_id, stars_amount, status, purchased_at) VALUES (?, ?, ?, 'completed', datetime('now'))",
126
+ [str(body.user_id), str(body.product_id), str(cost)],
 
 
 
 
 
127
  )
128
 
129
+ return {"ok": True, "data": {"processed": True, "product_id": body.product_id, "user_id": body.user_id}}
 
 
 
routers/social_feed.py DELETED
@@ -1,116 +0,0 @@
1
- """Social Feed API - المنشورات والتغذية الاجتماعية"""
2
- from fastapi import APIRouter, HTTPException, Request, Query
3
- from pydantic import BaseModel
4
- from typing import Optional
5
- from database import fetch_one, fetch_all, execute
6
- from auth import get_current_user
7
- from ratelimit import limiter
8
-
9
- router = APIRouter()
10
-
11
- async def _resolve_user(request: Request) -> dict:
12
- user_data = await get_current_user(request)
13
- user = await fetch_one("SELECT * FROM users WHERE user_id = ?", [str(user_data["id"])])
14
- if not user:
15
- raise HTTPException(status_code=404, detail="User not found")
16
- return user
17
-
18
- class CreatePost(BaseModel):
19
- content: str
20
- post_type: str = "status" # review, rating, watch, achievement, party, status
21
- media_type: Optional[str] = None
22
- media_id: Optional[int] = None
23
- is_spoiler: bool = False
24
- tags: Optional[list[str]] = None
25
-
26
- class PostAction(BaseModel):
27
- action: str # like, unlike, save, unsave
28
-
29
- @router.get("/feed")
30
- async def get_feed(request: Request, page: int = Query(1, ge=1), per_page: int = Query(20, ge=1, le=50)):
31
- """Get social feed with pagination"""
32
- user = await _resolve_user(request)
33
- offset = (page - 1) * per_page
34
- total = await fetch_one("SELECT COUNT(*) as cnt FROM social_posts WHERE status='active'")
35
- total_count = total["cnt"] if total else 0
36
-
37
- posts = await fetch_all(f"""
38
- SELECT sp.*, u.username, u.first_name, u.photo_url, u.is_premium,
39
- COALESCE(sl.liked, 0) as user_liked
40
- FROM social_posts sp
41
- LEFT JOIN users u ON sp.user_id = u.id
42
- LEFT JOIN (SELECT post_id, 1 as liked FROM social_likes WHERE user_id=?) sl ON sl.post_id = sp.id
43
- WHERE sp.status='active'
44
- ORDER BY sp.created_at DESC
45
- LIMIT {per_page} OFFSET {offset}""", [str(user["id"])])
46
-
47
- return {"ok": True, "data": posts, "meta": {"page": page, "per_page": per_page, "total": total_count}}
48
-
49
- @router.post("/feed")
50
- @limiter.limit("10/minute")
51
- async def create_post(body: CreatePost, request: Request):
52
- """Create a new social post"""
53
- user = await _resolve_user(request)
54
- tags_str = ",".join(body.tags) if body.tags else ""
55
- await execute(
56
- """INSERT INTO social_posts (user_id, post_type, content, media_type, media_id, is_spoiler, tags, status, created_at)
57
- VALUES (?, ?, ?, ?, ?, ?, ?, 'active', datetime('now'))""",
58
- [str(user["id"]), body.post_type, body.content, body.media_type or "",
59
- str(body.media_id) if body.media_id else None, "1" if body.is_spoiler else "0", tags_str]
60
- )
61
- return {"ok": True, "data": {"created": True}}
62
-
63
- @router.post("/feed/{post_id}/{action}")
64
- async def interact_with_post(post_id: int, action: str, request: Request):
65
- """Like/unlike/save/unsave a post"""
66
- user = await _resolve_user(request)
67
-
68
- if action == "like":
69
- await execute("INSERT OR IGNORE INTO social_likes (post_id, user_id) VALUES (?, ?)", [str(post_id), str(user["id"])])
70
- await execute("UPDATE social_posts SET likes_count = likes_count + 1 WHERE id = ?", [str(post_id)])
71
- elif action == "unlike":
72
- await execute("DELETE FROM social_likes WHERE post_id=? AND user_id=?", [str(post_id), str(user["id"])])
73
- await execute("UPDATE social_posts SET likes_count = MAX(0, likes_count - 1) WHERE id = ?", [str(post_id)])
74
- elif action == "save":
75
- await execute("INSERT OR IGNORE INTO social_saves (post_id, user_id) VALUES (?, ?)", [str(post_id), str(user["id"])])
76
- elif action == "unsave":
77
- await execute("DELETE FROM social_saves WHERE post_id=? AND user_id=?", [str(post_id), str(user["id"])])
78
-
79
- return {"ok": True, "data": {"action": action, "success": True}}
80
-
81
- @router.get("/feed/friends")
82
- async def get_friends_feed(request: Request, page: int = Query(1, ge=1)):
83
- """Get feed from friends only"""
84
- user = await _resolve_user(request)
85
- offset = (page - 1) * 20
86
- posts = await fetch_all(f"""
87
- SELECT sp.*, u.username, u.first_name, u.photo_url
88
- FROM social_posts sp
89
- JOIN users u ON sp.user_id = u.id
90
- WHERE sp.user_id IN (
91
- SELECT friend_id FROM friendships WHERE user_id = ? AND status='accepted'
92
- UNION SELECT user_id FROM friendships WHERE friend_id = ? AND status='accepted'
93
- ) AND sp.status='active'
94
- ORDER BY sp.created_at DESC LIMIT 20 OFFSET {offset}""",
95
- [str(user["user_id"]), str(user["user_id"])]
96
- )
97
- return {"ok": True, "data": posts}
98
-
99
- @router.get("/feed/trending")
100
- async def get_trending_posts(request: Request):
101
- """Get trending posts (most likes in last 24h)"""
102
- posts = await fetch_all("""
103
- SELECT sp.*, u.username, u.first_name, u.photo_url
104
- FROM social_posts sp
105
- JOIN users u ON sp.user_id = u.id
106
- WHERE sp.created_at >= datetime('now', '-1 day') AND sp.status='active'
107
- ORDER BY sp.likes_count DESC LIMIT 10"""
108
- )
109
- return {"ok": True, "data": posts}
110
-
111
- @router.delete("/feed/{post_id}")
112
- async def delete_post(post_id: int, request: Request):
113
- """Delete own post"""
114
- user = await _resolve_user(request)
115
- await execute("UPDATE social_posts SET status='deleted' WHERE id=? AND user_id=?", [str(post_id), str(user["id"])])
116
- return {"ok": True, "data": {"deleted": True}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
routers/user.py CHANGED
@@ -6,7 +6,6 @@ from typing import Optional
6
  from database import fetch_one, fetch_all, execute
7
  from auth import get_current_user
8
  from config import TMDB_IMAGE_BASE, DEV_MODE
9
- from ratelimit import limiter
10
 
11
  router = APIRouter()
12
 
@@ -218,7 +217,7 @@ async def get_daily_streak(request: Request):
218
 
219
 
220
  @router.post("/me/daily-streak/claim")
221
- @limiter.limit("5/minute")
222
  async def claim_daily_streak(request: Request):
223
  user = await _resolve_user(request)
224
  today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
 
6
  from database import fetch_one, fetch_all, execute
7
  from auth import get_current_user
8
  from config import TMDB_IMAGE_BASE, DEV_MODE
 
9
 
10
  router = APIRouter()
11
 
 
217
 
218
 
219
  @router.post("/me/daily-streak/claim")
220
+ @__import__('ratelimit').limiter.limit("5/minute")
221
  async def claim_daily_streak(request: Request):
222
  user = await _resolve_user(request)
223
  today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
routers/utils.py CHANGED
@@ -1,97 +1,78 @@
1
- """
2
- Shared utility functions for PopCorn API routers
3
- """
4
  from database import fetch_one, fetch_all, execute
5
- from config import ACCOUNTS, ORG, DEV_MODE
6
 
7
- ACHIEVEMENT_THRESHOLDS = {
8
- "watching": [1, 5, 10, 25, 50, 100, 250, 500, 1000],
9
- "social": [1, 5, 10, 25, 50, 100],
10
- "store": [1, 5, 10, 25, 50],
11
- "comments": [1, 10, 25, 50, 100, 500],
12
- "parties": [1, 5, 10, 25, 50],
13
- "streak": [1, 3, 7, 14, 30, 60, 90, 180, 365],
14
- }
15
-
16
-
17
- async def progress_achievement(user_id: int | str, category: str, current_count: int) -> None:
18
- """Progress an achievement category. Unlock it if threshold reached."""
19
- thresholds = ACHIEVEMENT_THRESHOLDS.get(category, [])
20
- target_count = None
21
- for t in thresholds:
22
- if current_count >= t:
23
- target_count = t
24
-
25
- if target_count is None:
26
- return
27
 
 
28
  achievement = await fetch_one(
29
- "SELECT id FROM achievements WHERE category=? AND total_required=?",
30
- [category, str(target_count)],
31
  )
32
  if not achievement:
33
  return
34
 
35
- existing = await fetch_one(
 
36
  "SELECT * FROM user_achievements WHERE user_id=? AND achievement_id=?",
37
- [str(user_id), str(achievement["id"])],
38
  )
39
- if existing and existing.get("is_unlocked"):
40
- return
41
 
42
- await execute(
43
- """INSERT INTO user_achievements (user_id, achievement_id, progress, is_unlocked, unlocked_at)
44
- VALUES (?, ?, ?, 1, datetime('now'))
45
- ON CONFLICT(user_id, achievement_id) DO UPDATE SET
46
- progress=?, is_unlocked=1, unlocked_at=CASE WHEN is_unlocked=0 THEN datetime('now') ELSE unlocked_at END""",
47
- [str(user_id), str(achievement["id"]), str(target_count), str(target_count)],
48
- )
 
 
 
 
 
 
 
 
 
49
 
50
 
51
- async def create_notification(user_id: int | str, type: str, title: str, body: str, data: str = "{}") -> dict:
52
- """Create a notification for a user."""
53
- await execute(
54
- """INSERT INTO notifications (user_id, type, title, body, data, is_read, created_at)
55
- VALUES (?, ?, ?, ?, ?, 0, datetime('now'))""",
56
- [str(user_id), type, title, body, data],
57
  )
58
- return {"created": True}
59
-
 
 
60
 
61
- async def create_or_update_user(user_data: dict) -> dict:
62
- """Create a user if not exists, or update the existing one."""
63
- tg_id = str(user_data["id"])
64
- existing = await fetch_one("SELECT * FROM users WHERE user_id=?", [tg_id])
65
- if existing:
66
- await execute(
67
- "UPDATE users SET first_name=?, last_name=?, username=?, language_code=?, photo_url=?, is_premium=?, last_active_at=datetime('now') WHERE user_id=?",
68
- [
69
- user_data.get("first_name", ""),
70
- user_data.get("last_name", ""),
71
- user_data.get("username", ""),
72
- user_data.get("language_code", "en"),
73
- user_data.get("photo_url", ""),
74
- "1" if user_data.get("is_premium") else "0",
75
- tg_id,
76
- ],
77
  )
78
- return existing
79
 
80
- await execute(
81
- """INSERT INTO users (user_id, first_name, last_name, username, language_code, photo_url, is_premium, stars_balance, kernels_balance, points, created_at, last_active_at)
82
- VALUES (?, ?, ?, ?, ?, ?, ?, 100, 5, 0, datetime('now'), datetime('now'))""",
83
- [
84
- tg_id,
85
- user_data.get("first_name", ""),
86
- user_data.get("last_name", ""),
87
- user_data.get("username", ""),
88
- user_data.get("language_code", "en"),
89
- user_data.get("photo_url", ""),
90
- "1" if user_data.get("is_premium") else "0",
91
- ],
92
- )
93
- return await fetch_one("SELECT * FROM users WHERE user_id=?", [tg_id])
 
 
 
 
94
 
95
 
96
- def is_dev_mode() -> bool:
97
- return DEV_MODE
 
 
 
 
 
 
 
 
 
 
1
  from database import fetch_one, fetch_all, execute
 
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
+ async def check_achievement(user_id: int, category: str, current: int, required: int, name: str):
5
  achievement = await fetch_one(
6
+ "SELECT id FROM achievements WHERE name=? AND category=? AND total_required=?",
7
+ [name, category, str(required)],
8
  )
9
  if not achievement:
10
  return
11
 
12
+ aid = achievement["id"]
13
+ ua = await fetch_one(
14
  "SELECT * FROM user_achievements WHERE user_id=? AND achievement_id=?",
15
+ [str(user_id), str(aid)],
16
  )
 
 
17
 
18
+ if ua:
19
+ if ua.get("is_unlocked"):
20
+ return
21
+ if ua.get("progress", 0) >= ua.get("total", 1):
22
+ await execute(
23
+ "UPDATE user_achievements SET is_unlocked=1, unlocked_at=datetime('now') WHERE id=?",
24
+ [str(ua["id"])],
25
+ )
26
+ await create_notification(user_id, f"إنجاز مفتوح: {name}", f"لقد حصلت على إنجاز {name}!", "achievement_unlocked")
27
+ else:
28
+ await execute(
29
+ "INSERT INTO user_achievements (user_id, achievement_id, progress, total, is_unlocked) VALUES (?, ?, ?, ?, ?)",
30
+ [str(user_id), str(aid), str(current), str(required), "1" if current >= required else "0"],
31
+ )
32
+ if current >= required:
33
+ await create_notification(user_id, f"إنجاز مفتوح: {name}", f"لقد حصلت على إنجاز {name}!", "achievement_unlocked")
34
 
35
 
36
+ async def progress_achievement(user_id: int, category: str, increment: int = 1):
37
+ achievements = await fetch_all(
38
+ "SELECT id, name, total_required FROM achievements WHERE category=?",
39
+ [category],
 
 
40
  )
41
+ for a in achievements:
42
+ aid = a["id"]
43
+ total = a["total_required"]
44
+ name = a["name"]
45
 
46
+ ua = await fetch_one(
47
+ "SELECT * FROM user_achievements WHERE user_id=? AND achievement_id=?",
48
+ [str(user_id), str(aid)],
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  )
 
50
 
51
+ if ua:
52
+ if ua.get("is_unlocked"):
53
+ continue
54
+ new_progress = (ua.get("progress", 0) or 0) + increment
55
+ is_unlocked = "1" if new_progress >= total else "0"
56
+ await execute(
57
+ "UPDATE user_achievements SET progress=?, is_unlocked=?, unlocked_at=CASE WHEN ?=1 THEN datetime('now') ELSE NULL END WHERE id=?",
58
+ [str(new_progress), is_unlocked, is_unlocked, str(ua["id"])],
59
+ )
60
+ if new_progress >= total:
61
+ await create_notification(user_id, f"إنجاز مفتوح: {name}", f"لقد حصلت على إنجاز {name}!", "achievement_unlocked")
62
+ else:
63
+ await execute(
64
+ "INSERT INTO user_achievements (user_id, achievement_id, progress, total, is_unlocked) VALUES (?, ?, ?, ?, ?)",
65
+ [str(user_id), str(aid), str(increment), str(total), "1" if increment >= total else "0"],
66
+ )
67
+ if increment >= total:
68
+ await create_notification(user_id, f"إنجاز مفتوح: {name}", f"لقد حصلت على إنجاز {name}!", "achievement_unlocked")
69
 
70
 
71
+ async def create_notification(user_id: int, title: str, body: str, ntype: str = "info"):
72
+ try:
73
+ await execute(
74
+ "INSERT INTO notifications (user_id, title, body, type) VALUES (?, ?, ?, ?)",
75
+ [str(user_id), title, body, ntype],
76
+ )
77
+ except Exception:
78
+ pass
routers/websocket.py DELETED
@@ -1,301 +0,0 @@
1
- """
2
- Real-time WebSocket Manager for PopCorn
3
- Handles: Watch Parties, Chat, Notifications, and Live Events
4
- """
5
- import asyncio
6
- import json
7
- import time
8
- import uuid
9
- from typing import Any
10
- from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Request, HTTPException
11
- from database import fetch_one, fetch_all, execute
12
- from auth import verify_init_data, get_current_user
13
-
14
- router = APIRouter()
15
-
16
- # ─── Connection Manager ───────────────────────────────────────────────────────
17
-
18
- class ConnectionManager:
19
- def __init__(self):
20
- self._rooms: dict[str, set[WebSocket]] = {} # room_code -> {ws}
21
- self._user_ws: dict[int, set[WebSocket]] = {} # user_id -> {ws}
22
- self._room_owners: dict[str, int] = {} # room_code -> host_user_id
23
- self._room_media: dict[str, dict] = {} # room_code -> media info
24
- self._room_participants: dict[str, dict] = {} # room_code -> {user_id: participant_info}
25
-
26
- async def connect_to_room(self, room_code: str, ws: WebSocket, user_id: int, user_data: dict, role: str = "follower"):
27
- await ws.accept()
28
- if room_code not in self._rooms:
29
- self._rooms[room_code] = set()
30
- self._rooms[room_code].add(ws)
31
-
32
- if user_id not in self._user_ws:
33
- self._user_ws[user_id] = set()
34
- self._user_ws[user_id].add(ws)
35
-
36
- # Store participant info
37
- if room_code not in self._room_participants:
38
- self._room_participants[room_code] = {}
39
- self._room_participants[room_code][user_id] = {
40
- "user_id": user_id,
41
- "name": user_data.get("first_name", "Unknown"),
42
- "username": user_data.get("username", ""),
43
- "avatar_url": user_data.get("photo_url", ""),
44
- "role": role,
45
- "is_ready": False,
46
- "joined_at": time.time(),
47
- "is_online": True,
48
- }
49
-
50
- # Notify others
51
- await self.broadcast_to_room(room_code, {
52
- "type": "user_joined",
53
- "participant": self._room_participants[room_code][user_id],
54
- }, exclude=ws)
55
-
56
- # Send current state
57
- await ws.send_json({
58
- "type": "room_state",
59
- "participants": list(self._room_participants[room_code].values()),
60
- "media": self._room_media.get(room_code),
61
- "room_code": room_code,
62
- "your_role": role,
63
- })
64
-
65
- async def disconnect(self, room_code: str, ws: WebSocket, user_id: int):
66
- if room_code in self._rooms:
67
- self._rooms[room_code].discard(ws)
68
- if not self._rooms[room_code]:
69
- del self._rooms[room_code]
70
- self._room_participants.pop(room_code, None)
71
- self._room_media.pop(room_code, None)
72
-
73
- if user_id in self._user_ws:
74
- self._user_ws[user_id].discard(ws)
75
- if not self._user_ws[user_id]:
76
- del self._user_ws[user_id]
77
-
78
- # Remove participant and notify
79
- if room_code in self._room_participants:
80
- self._room_participants[room_code].pop(user_id, None)
81
- await self.broadcast_to_room(room_code, {
82
- "type": "user_left",
83
- "user_id": user_id,
84
- })
85
-
86
- async def broadcast_to_room(self, room_code: str, message: dict, exclude: WebSocket | None = None):
87
- if room_code not in self._rooms:
88
- return
89
- dead = set()
90
- for ws in self._rooms[room_code]:
91
- if ws == exclude:
92
- continue
93
- try:
94
- await ws.send_json(message)
95
- except Exception:
96
- dead.add(ws)
97
- for ws in dead:
98
- self._rooms[room_code].discard(ws)
99
-
100
- async def send_to_user(self, user_id: int, message: dict):
101
- if user_id not in self._user_ws:
102
- return
103
- dead = set()
104
- for ws in self._user_ws[user_id]:
105
- try:
106
- await ws.send_json(message)
107
- except Exception:
108
- dead.add(ws)
109
- for ws in dead:
110
- self._user_ws[user_id].discard(ws)
111
-
112
- def get_room_participants_count(self, room_code: str) -> int:
113
- return len(self._room_participants.get(room_code, {}))
114
-
115
-
116
- manager = ConnectionManager()
117
-
118
-
119
- # ─── WebSocket Endpoint ───────────────────────────────────────────────────────
120
-
121
- @router.websocket("/party/{room_code}")
122
- async def party_websocket(websocket: WebSocket, room_code: str):
123
- user_id = None
124
- user_data = None
125
-
126
- try:
127
- # Wait for auth message first
128
- raw = await websocket.receive_text()
129
- auth_data = json.loads(raw)
130
-
131
- if auth_data.get("type") != "auth":
132
- await websocket.close(code=4001, reason="Auth required")
133
- return
134
-
135
- init_data = auth_data.get("initData", "")
136
- user_data = verify_init_data(init_data)
137
- if not user_data or not user_data.get("id"):
138
- await websocket.close(code=4001, reason="Invalid auth")
139
- return
140
-
141
- user_id = user_data["id"]
142
- role = auth_data.get("role", "follower")
143
-
144
- # Verify room exists
145
- room = await fetch_one(
146
- "SELECT * FROM party_rooms WHERE room_code = ? AND status != 'ended'",
147
- [room_code],
148
- )
149
- if not room:
150
- await websocket.send_json({"type": "error", "code": "ROOM_NOT_FOUND", "message": "Party room not found"})
151
- await websocket.close(code=4004, reason="Room not found")
152
- return
153
-
154
- await manager.connect_to_room(room_code, websocket, user_id, user_data, role)
155
-
156
- # Main message loop
157
- while True:
158
- raw = await websocket.receive_text()
159
- msg = json.loads(raw)
160
- msg_type = msg.get("type")
161
-
162
- if msg_type == "ping":
163
- await websocket.send_json({"type": "pong", "timestamp": time.time()})
164
-
165
- elif msg_type == "sync":
166
- # Leader sends sync; broadcast to all others
167
- if manager._room_participants.get(room_code, {}).get(user_id, {}).get("role") == "leader":
168
- await manager.broadcast_to_room(room_code, {
169
- "type": "sync",
170
- "is_playing": msg.get("is_playing", False),
171
- "position": msg.get("position", 0),
172
- "timestamp": time.time(),
173
- "playback_rate": msg.get("playback_rate", 1.0),
174
- }, exclude=websocket)
175
-
176
- elif msg_type == "chat":
177
- message_data = {
178
- "type": "chat_message",
179
- "message": {
180
- "id": str(uuid.uuid4()),
181
- "user_id": str(user_id),
182
- "user_name": user_data.get("first_name", "Unknown"),
183
- "content": msg.get("content", ""),
184
- "type": "chat",
185
- "reply_to_id": msg.get("reply_to_id"),
186
- "created_at": time.time(),
187
- },
188
- }
189
- await manager.broadcast_to_room(room_code, message_data)
190
-
191
- elif msg_type == "reaction":
192
- await manager.broadcast_to_room(room_code, {
193
- "type": "reaction",
194
- "emoji": msg.get("emoji", "👍"),
195
- "target_id": msg.get("target_id"),
196
- "target_type": msg.get("target_type", "message"),
197
- "user_id": str(user_id),
198
- })
199
-
200
- elif msg_type == "ready":
201
- is_ready = msg.get("is_ready", False)
202
- if room_code in manager._room_participants and user_id in manager._room_participants[room_code]:
203
- manager._room_participants[room_code][user_id]["is_ready"] = is_ready
204
-
205
- await manager.broadcast_to_room(room_code, {
206
- "type": "ready",
207
- "user_id": str(user_id),
208
- "is_ready": is_ready,
209
- })
210
-
211
- elif msg_type == "buffering":
212
- await manager.broadcast_to_room(room_code, {
213
- "type": "buffering",
214
- "user_id": str(user_id),
215
- "is_buffering": msg.get("is_buffering", False),
216
- })
217
-
218
- elif msg_type == "leave":
219
- break
220
-
221
- except WebSocketDisconnect:
222
- pass
223
- except Exception as e:
224
- print(f"[ws] error: {e}")
225
- finally:
226
- if room_code and user_id:
227
- await manager.disconnect(room_code, websocket, user_id)
228
-
229
-
230
- # ─── Chat WebSocket ────────────────────────────────────────────────────────────
231
-
232
- @router.websocket("/chat/{conversation_id}")
233
- async def chat_websocket(websocket: WebSocket, conversation_id: str):
234
- user_id = None
235
- try:
236
- raw = await websocket.receive_text()
237
- auth_data = json.loads(raw)
238
- init_data = auth_data.get("initData", "")
239
- user_data = verify_init_data(init_data)
240
- if not user_data or not user_data.get("id"):
241
- await websocket.close(code=4001)
242
- return
243
-
244
- user_id = user_data["id"]
245
- await websocket.accept()
246
-
247
- while True:
248
- raw = await websocket.receive_text()
249
- msg = json.loads(raw)
250
- if msg.get("type") == "ping":
251
- await websocket.send_json({"type": "pong"})
252
- elif msg.get("type") == "message":
253
- content = msg.get("content", "").strip()
254
- if content:
255
- msg_id = str(uuid.uuid4())
256
- await execute(
257
- "INSERT INTO chat_messages (id, conversation_id, sender_id, content, created_at) VALUES (?, ?, ?, ?, datetime('now'))",
258
- [msg_id, conversation_id, str(user_id), content],
259
- )
260
- # Update conversation last_message
261
- await execute(
262
- "UPDATE conversations SET last_message = ?, last_message_at = datetime('now') WHERE id = ?",
263
- [content, conversation_id],
264
- )
265
- await websocket.send_json({
266
- "type": "message_sent",
267
- "id": msg_id,
268
- "content": content,
269
- "sender_id": str(user_id),
270
- "created_at": time.time(),
271
- })
272
- except WebSocketDisconnect:
273
- pass
274
- except Exception as e:
275
- print(f"[ws-chat] error: {e}")
276
-
277
-
278
- # ─── REST Endpoints for Room Management ───────────────────────────────────────
279
-
280
- @router.get("/room/{room_code}/participants")
281
- async def get_room_participants(room_code: str):
282
- participants = manager._room_participants.get(room_code, {})
283
- return {"ok": True, "data": {"participants": list(participants.values()), "count": len(participants)}}
284
-
285
-
286
- @router.post("/room/{room_code}/media")
287
- async def set_room_media(room_code: str, request: Request):
288
- body = await request.json()
289
- manager._room_media[room_code] = body
290
- await manager.broadcast_to_room(room_code, {"type": "media_changed", "media": body})
291
- return {"ok": True}
292
-
293
-
294
- @router.post("/room/{room_code}/kick")
295
- async def kick_participant(room_code: str, request: Request):
296
- body = await request.json()
297
- target_user_id = body.get("user_id")
298
- if target_user_id:
299
- await manager.send_to_user(target_user_id, {"type": "kicked", "user_id": str(target_user_id)})
300
- return {"ok": True}
301
- raise HTTPException(status_code=400, detail="Missing user_id")