api / routers /user.py
jamelloverz-sketch
PopCorn API v3.5 - Media metadata, people, images, open CORS
c18e004
Raw
History Blame Contribute Delete
12.4 kB
import json
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request, Query
from pydantic import BaseModel
from typing import Optional
from database import fetch_one, fetch_all, execute
from auth import get_current_user
from config import TMDB_IMAGE_BASE, DEV_MODE
from ratelimit import limiter
router = APIRouter()
async def _resolve_user(request: Request) -> dict:
user_data = await get_current_user(request)
user = await fetch_one("SELECT * FROM users WHERE user_id = ?", [str(user_data["id"])])
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
class ProfileUpdate(BaseModel):
first_name: Optional[str] = None
last_name: Optional[str] = None
username: Optional[str] = None
photo_url: Optional[str] = None
class RatingRequest(BaseModel):
tmdb_id: int
media_type: str
rating: int
review: Optional[str] = None
class AssetActivate(BaseModel):
asset_item_id: str
product_id: str
class ListAdd(BaseModel):
tmdb_id: int
media_type: str
title: str
poster_url: Optional[str] = None
class ListRemove(BaseModel):
tmdb_id: int
media_type: str
@router.get("/me")
async def get_my_profile(request: Request):
user = await _resolve_user(request)
watch_count = await fetch_one("SELECT COUNT(*) as cnt FROM watch_history WHERE user_id = ?", [str(user["id"])])
series_completed = await fetch_one(
"SELECT COUNT(DISTINCT tmdb_id) as cnt FROM watch_history WHERE user_id = ? AND media_type='tv' AND completed=1",
[str(user["id"])],
)
friends_count = await fetch_one(
"SELECT COUNT(*) as cnt FROM friendships WHERE (user_id = ? OR friend_id = ?) AND status='accepted'",
[str(user["user_id"]), str(user["user_id"])],
)
return {
"ok": True,
"data": {
"id": user["id"],
"user_id": user["user_id"],
"username": user.get("username", ""),
"first_name": user.get("first_name", ""),
"last_name": user.get("last_name", ""),
"photo_url": user.get("photo_url", ""),
"language_code": user.get("language_code", "en"),
"is_premium": bool(user.get("is_premium")),
"stars_balance": user.get("stars_balance", 0),
"kernels_balance": user.get("kernels_balance", 0),
"total_watch_time": user.get("total_watch_time", 0),
"last_active_at": user.get("last_active_at", ""),
"watch_count": watch_count["cnt"] if watch_count else 0,
"series_completed": series_completed["cnt"] if series_completed else 0,
"friends_count": friends_count["cnt"] if friends_count else 0,
},
}
@router.patch("/me")
async def update_profile(body: ProfileUpdate, request: Request):
user = await _resolve_user(request)
updates = {}
if body.first_name is not None:
updates["first_name"] = body.first_name
if body.last_name is not None:
updates["last_name"] = body.last_name
if body.username is not None:
updates["username"] = body.username
if body.photo_url is not None:
updates["photo_url"] = body.photo_url
if updates:
set_clause = ", ".join(f"{k}=?" for k in updates)
vals = [str(v) for v in updates.values()] + [str(user["user_id"])]
await execute(f"UPDATE users SET {set_clause} WHERE user_id=?", vals)
return {"ok": True, "data": {"updated": list(updates.keys())}}
# Ratings
@router.post("/rating")
async def create_rating(body: RatingRequest, request: Request):
user = await _resolve_user(request)
if body.rating < 1 or body.rating > 10:
raise HTTPException(status_code=400, detail="Rating must be 1-10")
await execute(
"""INSERT INTO ratings (user_id, tmdb_id, media_type, rating, review, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, tmdb_id, media_type)
DO UPDATE SET rating=?, review=?, updated_at=datetime('now')""",
[
str(user["id"]), str(body.tmdb_id), body.media_type,
str(body.rating), body.review or "",
str(body.rating), body.review or "",
],
)
return {"ok": True, "data": {"saved": True}}
@router.get("/me/ratings")
async def get_my_ratings(request: Request):
user = await _resolve_user(request)
rows = await fetch_all(
"SELECT * FROM ratings WHERE user_id = ? ORDER BY updated_at DESC", [str(user["id"])]
)
return {"ok": True, "data": rows}
# Assets
@router.get("/me/assets")
async def get_my_assets(request: Request):
user = await _resolve_user(request)
rows = await fetch_all(
"""SELECT ua.*, ai.name_en, ai.image_url, ai.rarity, ai.type as asset_type_name
FROM user_assets ua
LEFT JOIN asset_items ai ON ua.asset_id = ai.id
WHERE ua.user_id = ? ORDER BY ua.acquired_at DESC""",
[str(user["id"])],
)
return {"ok": True, "data": rows}
@router.post("/me/asset/activate")
async def activate_asset(body: AssetActivate, request: Request):
user = await _resolve_user(request)
await execute(
"UPDATE user_assets SET is_active=0 WHERE user_id=?",
[str(user["id"])],
)
await execute(
"UPDATE user_assets SET is_active=1 WHERE user_id=? AND asset_item_id=? AND product_id=?",
[str(user["id"]), body.asset_item_id, body.product_id],
)
return {"ok": True, "data": {"activated": True}}
# Wallet
@router.get("/me/wallet")
async def get_wallet(request: Request):
user = await _resolve_user(request)
return {
"ok": True,
"data": {
"stars": user.get("stars_balance", 0),
"kernels": user.get("kernels_balance", 0),
"points": user.get("points", 0),
},
}
@router.get("/me/wallet/kernels")
async def get_kernels(request: Request):
user = await _resolve_user(request)
return {
"ok": True,
"data": {
"balance": user.get("kernels_balance", 0),
"total_earned": user.get("total_earned_kernels", 0),
"total_spent": user.get("total_spent_kernels", 0),
},
}
# Daily Streak
@router.get("/me/daily-streak")
async def get_daily_streak(request: Request):
user = await _resolve_user(request)
streak = await fetch_one("SELECT * FROM daily_streaks WHERE user_id = ?", [str(user["id"])])
if not streak:
return {"ok": True, "data": {"streak": 0, "can_claim": True, "days": []}}
last_claim = streak.get("last_claim_date", "")
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
can_claim = last_claim != today
return {
"ok": True,
"data": {
"streak": streak.get("streak_count", 0),
"last_claim": last_claim or "",
"can_claim": can_claim or DEV_MODE,
"today_claimed": not can_claim,
"reward_today": 10,
"next_reward": 10,
},
}
@router.post("/me/daily-streak/claim")
@limiter.limit("5/minute")
async def claim_daily_streak(request: Request):
user = await _resolve_user(request)
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
streak = await fetch_one("SELECT * FROM daily_streaks WHERE user_id = ?", [str(user["id"])])
if streak:
last_claim = streak.get("last_claim_date", "")
if last_claim == today and not DEV_MODE:
raise HTTPException(status_code=400, detail="Already claimed today")
yesterday = datetime.now(timezone.utc).timestamp() - 86400
yesterday_str = datetime.fromtimestamp(yesterday, tz=timezone.utc).strftime("%Y-%m-%d")
if last_claim == yesterday_str or not last_claim:
new_count = (streak.get("streak_count", 0) or 0) + 1
else:
new_count = 1
await execute(
"UPDATE daily_streaks SET streak_count=?, last_claim_date=? WHERE user_id=?",
[str(new_count), today, str(user["id"])],
)
else:
new_count = 1
await execute(
"INSERT INTO daily_streaks (user_id, streak_count, last_claim_date) VALUES (?, 1, ?)",
[str(user["id"]), today],
)
await execute("UPDATE users SET stars_balance = stars_balance + 10 WHERE id = ?", [str(user["id"])])
return {"ok": True, "data": {"claimed": True, "streak": new_count, "reward": 10}}
# Earnings
@router.get("/me/earnings")
async def get_earnings(request: Request):
user = await _resolve_user(request)
return {"ok": True, "data": {"kernels": user.get("kernels_balance", 0), "history": []}}
# Watch List
@router.get("/me/list")
async def get_my_list(request: Request):
user = await _resolve_user(request)
rows = await fetch_all(
"SELECT * FROM user_list WHERE user_id = ? ORDER BY added_at DESC", [str(user["id"])]
)
return {"ok": True, "data": rows}
@router.post("/me/list")
async def add_to_list(body: ListAdd, request: Request):
user = await _resolve_user(request)
await execute(
"""INSERT OR IGNORE INTO user_list (user_id, tmdb_id, media_type, title, poster_url)
VALUES (?, ?, ?, ?, ?)""",
[str(user["id"]), str(body.tmdb_id), body.media_type, body.title, body.poster_url or ""],
)
return {"ok": True, "data": {"added": True}}
@router.delete("/me/list")
async def remove_from_list(body: ListRemove, request: Request):
user = await _resolve_user(request)
await execute(
"DELETE FROM user_list WHERE user_id=? AND tmdb_id=? AND media_type=?",
[str(user["id"]), str(body.tmdb_id), body.media_type],
)
return {"ok": True, "data": {"removed": True}}
# Watch History
@router.get("/me/history")
async def get_watch_history(request: Request, page: int = Query(1, ge=1)):
user = await _resolve_user(request)
offset = (page - 1) * 20
rows = await fetch_all(
f"""SELECT wh.*, a.title, a.poster_path, a.quality, a.year
FROM watch_history wh
LEFT JOIN (SELECT DISTINCT media_id, title, poster_path, quality, year FROM archives) a
ON wh.tmdb_id = a.media_id
WHERE wh.user_id = ?
ORDER BY wh.last_watched_at DESC LIMIT 20 OFFSET {offset}""",
[str(user["id"])],
)
for r in rows:
pp = r.get("poster_path", "")
if pp and not pp.startswith("http"):
r["poster_url"] = f"{TMDB_IMAGE_BASE}/w500{pp}" if pp.startswith("/") else pp
else:
r["poster_url"] = pp or ""
return {"ok": True, "data": rows}
# Purchases
@router.get("/me/purchases")
async def get_purchases(request: Request):
user = await _resolve_user(request)
rows = await fetch_all(
"""SELECT p.*, pr.name_en, pr.name_ar, pr.image_url, pr.category
FROM purchases p
LEFT JOIN products pr ON p.product_id = pr.id
WHERE p.user_id = ? ORDER BY p.purchased_at DESC""",
[str(user["id"])],
)
return {"ok": True, "data": rows}
# User profile (other users)
@router.get("/user/{user_id}")
async def get_user_profile(user_id: int):
user = await fetch_one("SELECT * FROM users WHERE user_id = ?", [str(user_id)])
if not user:
raise HTTPException(status_code=404, detail="User not found")
watch_stats = await fetch_one(
"SELECT COUNT(*) as watch_count, COALESCE(SUM(duration_seconds), 0) as total_watch_time FROM watch_history WHERE user_id=?",
[str(user["id"])],
)
total_watch_time = watch_stats["total_watch_time"] if watch_stats else 0
watch_count = watch_stats["watch_count"] if watch_stats else 0
return {
"ok": True,
"data": {
"user_id": user["user_id"],
"username": user.get("username", ""),
"first_name": user.get("first_name", ""),
"last_name": user.get("last_name", ""),
"photo_url": user.get("photo_url", ""),
"is_premium": bool(user.get("is_premium")),
"total_watch_time": total_watch_time,
"points": user.get("points", 0),
"stats": {
"total_watch_time": total_watch_time,
"watch_count": watch_count,
"points": user.get("points", 0),
"kernels_balance": user.get("kernels_balance", 0),
},
},
}