Spaces:
Runtime error
Runtime error
fix(cors): add popcornminisite.github.io to allowed origins
#1
- .gitignore +0 -4
- __pycache__/app.cpython-310.pyc +0 -0
- app.py +163 -38
- config.py +14 -10
- database.py +11 -42
- db_init.py +0 -128
- requirements.txt +0 -2
- routers/analytics.py +0 -79
- routers/clubs.py +0 -113
- routers/gamification.py +0 -93
- routers/media.py +0 -83
- routers/parties.py +2 -4
- routers/payments.py +34 -105
- routers/social_feed.py +0 -116
- routers/user.py +1 -2
- routers/utils.py +59 -78
- routers/websocket.py +0 -301
.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
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 47 |
-
|
| 48 |
|
| 49 |
_CACHEABLE_PATHS = (
|
| 50 |
-
"/api/v1/health",
|
| 51 |
-
"/api/v1/movies/
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
)
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
| 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={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
response = await call_next(request)
|
| 64 |
-
if response.status_code != 200:
|
|
|
|
|
|
|
| 65 |
cached_ttl = 30
|
| 66 |
for prefix in _CACHEABLE_PATHS:
|
| 67 |
-
if path.startswith(prefix):
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
@asynccontextmanager
|
| 82 |
async def lifespan(app: FastAPI):
|
| 83 |
task = asyncio.create_task(keep_warm())
|
| 84 |
try:
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
| 87 |
for migration in _MIGRATIONS:
|
| 88 |
-
try:
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
yield
|
| 94 |
task.cancel()
|
| 95 |
-
try:
|
| 96 |
-
|
|
|
|
|
|
|
| 97 |
await close_session()
|
| 98 |
|
| 99 |
-
|
|
|
|
|
|
|
| 100 |
app.state.limiter = limiter
|
| 101 |
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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.
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 37 |
-
PROXY_CACHE_TTL = 3600
|
| 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
|
| 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
|
| 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 |
-
|
| 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(
|
| 52 |
else:
|
| 53 |
-
result += _escape(
|
| 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 |
-
|
| 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,
|
| 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 = "
|
| 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/{
|
| 57 |
|
| 58 |
return {
|
| 59 |
"ok": True,
|
| 60 |
"data": {
|
| 61 |
"invoice_link": invoice_link,
|
| 62 |
"payload": payload,
|
| 63 |
-
"amount":
|
| 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 |
-
|
| 80 |
-
|
| 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 |
-
|
| 90 |
-
[str(cost), str(cost), str(user["id"])],
|
| 91 |
-
)
|
| 92 |
else:
|
| 93 |
-
|
| 94 |
-
cost = cost_stars
|
| 95 |
balance = user.get("stars_balance", 0)
|
| 96 |
-
if balance
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 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
|
| 132 |
-
[str(user["id"]), str(body.product_id)
|
| 133 |
)
|
| 134 |
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
"
|
| 138 |
-
[str(user["id"])],
|
| 139 |
)
|
|
|
|
|
|
|
| 140 |
if purchase_count:
|
| 141 |
-
await progress_achievement(user["
|
| 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 |
-
|
|
|
|
| 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
|
| 185 |
-
[str(body.user_id),
|
| 186 |
)
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 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(
|
| 31 |
)
|
| 32 |
if not achievement:
|
| 33 |
return
|
| 34 |
|
| 35 |
-
|
|
|
|
| 36 |
"SELECT * FROM user_achievements WHERE user_id=? AND achievement_id=?",
|
| 37 |
-
[str(user_id), str(
|
| 38 |
)
|
| 39 |
-
if existing and existing.get("is_unlocked"):
|
| 40 |
-
return
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
-
async def
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
VALUES (?, ?, ?, ?, ?, 0, datetime('now'))""",
|
| 56 |
-
[str(user_id), type, title, body, data],
|
| 57 |
)
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 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 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
|
| 96 |
-
def
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|