Spaces:
Running
Running
Allow signed-in users to read shared sessions
Browse files- README.md +1 -1
- package.json +30 -30
- src/analysis.ts +1 -1
- src/subscriptions.ts +72 -72
- start.py +55 -55
README.md
CHANGED
|
@@ -43,7 +43,7 @@ Node health: `GET /health`. AI health is internal at `http://127.0.0.1:8000/heal
|
|
| 43 |
|
| 44 |
Run one container with one Python worker: analysis jobs and retry payloads are in memory and are lost on restart. Temporary local files are ephemeral. Completed videos and `results.json` are stored in the private HF dataset; Node stores session metadata and frame indices in Supabase. Model constants are in `app.py`.
|
| 45 |
|
| 46 |
-
Apply `../Database/migrations/20261016_node_analysis.sql` before analyses. It assumes the legacy actions table is empty. Secret-protected `/internal/analysis` routes authorize sessions and persist completion/failure.
|
| 47 |
|
| 48 |
## Supabase dashboard setup
|
| 49 |
|
|
|
|
| 43 |
|
| 44 |
Run one container with one Python worker: analysis jobs and retry payloads are in memory and are lost on restart. Temporary local files are ephemeral. Completed videos and `results.json` are stored in the private HF dataset; Node stores session metadata and frame indices in Supabase. Model constants are in `app.py`.
|
| 45 |
|
| 46 |
+
Apply `../Database/migrations/20261016_node_analysis.sql` before analyses. It assumes the legacy actions table is empty. Apply `../Database/migrations/20261016_action_reviews.sql` to enable saving coach scores and per-action annotation notes. Secret-protected `/internal/analysis` routes authorize sessions and persist completion/failure.
|
| 47 |
|
| 48 |
## Supabase dashboard setup
|
| 49 |
|
package.json
CHANGED
|
@@ -1,30 +1,30 @@
|
|
| 1 |
-
{
|
| 2 |
-
"name": "sporalize-backend",
|
| 3 |
-
"private": true,
|
| 4 |
-
"version": "1.0.0",
|
| 5 |
-
"type": "module",
|
| 6 |
-
"scripts": {
|
| 7 |
-
"dev": "tsx watch src/server.ts",
|
| 8 |
-
"build": "tsc -p tsconfig.json",
|
| 9 |
-
"start": "python start.py"
|
| 10 |
-
},
|
| 11 |
-
"dependencies": {
|
| 12 |
-
"@supabase/supabase-js": "^2.57.0",
|
| 13 |
-
"bcryptjs": "^3.0.2",
|
| 14 |
-
"cookie-parser": "^1.4.7",
|
| 15 |
-
"cors": "^2.8.5",
|
| 16 |
-
"dotenv": "^16.6.1",
|
| 17 |
-
"express": "^5.1.0",
|
| 18 |
-
"express-rate-limit": "^8.1.0",
|
| 19 |
-
"helmet": "^8.1.0",
|
| 20 |
-
"zod": "^4.1.5"
|
| 21 |
-
},
|
| 22 |
-
"devDependencies": {
|
| 23 |
-
"@types/cookie-parser": "^1.4.9",
|
| 24 |
-
"@types/cors": "^2.8.19",
|
| 25 |
-
"@types/express": "^5.0.3",
|
| 26 |
-
"@types/node": "^22.16.5",
|
| 27 |
-
"tsx": "^4.20.5",
|
| 28 |
-
"typescript": "^5.8.3"
|
| 29 |
-
}
|
| 30 |
-
}
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "sporalize-backend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "1.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "tsx watch src/server.ts",
|
| 8 |
+
"build": "tsc -p tsconfig.json",
|
| 9 |
+
"start": "python start.py"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@supabase/supabase-js": "^2.57.0",
|
| 13 |
+
"bcryptjs": "^3.0.2",
|
| 14 |
+
"cookie-parser": "^1.4.7",
|
| 15 |
+
"cors": "^2.8.5",
|
| 16 |
+
"dotenv": "^16.6.1",
|
| 17 |
+
"express": "^5.1.0",
|
| 18 |
+
"express-rate-limit": "^8.1.0",
|
| 19 |
+
"helmet": "^8.1.0",
|
| 20 |
+
"zod": "^4.1.5"
|
| 21 |
+
},
|
| 22 |
+
"devDependencies": {
|
| 23 |
+
"@types/cookie-parser": "^1.4.9",
|
| 24 |
+
"@types/cors": "^2.8.19",
|
| 25 |
+
"@types/express": "^5.0.3",
|
| 26 |
+
"@types/node": "^22.16.5",
|
| 27 |
+
"tsx": "^4.20.5",
|
| 28 |
+
"typescript": "^5.8.3"
|
| 29 |
+
}
|
| 30 |
+
}
|
src/analysis.ts
CHANGED
|
@@ -62,7 +62,7 @@ export function analysisRouter(db: SupabaseClient, secret: string) {
|
|
| 62 |
try {
|
| 63 |
const sessionId = z.uuid().parse(req.params.sessionId);
|
| 64 |
const { data, error } = await db.from("sessions").select("session_id,player_id,path,status,name,created_at")
|
| 65 |
-
.eq("session_id", sessionId).
|
| 66 |
if (error) return res.status(503).json({ error: "Unable to load session" });
|
| 67 |
return data ? res.json(data) : res.sendStatus(404);
|
| 68 |
} catch (error) { next(error); }
|
|
|
|
| 62 |
try {
|
| 63 |
const sessionId = z.uuid().parse(req.params.sessionId);
|
| 64 |
const { data, error } = await db.from("sessions").select("session_id,player_id,path,status,name,created_at")
|
| 65 |
+
.eq("session_id", sessionId).maybeSingle();
|
| 66 |
if (error) return res.status(503).json({ error: "Unable to load session" });
|
| 67 |
return data ? res.json(data) : res.sendStatus(404);
|
| 68 |
} catch (error) { next(error); }
|
src/subscriptions.ts
CHANGED
|
@@ -1,72 +1,72 @@
|
|
| 1 |
-
import { supabaseAdmin } from "./supabase.js";
|
| 2 |
-
|
| 3 |
-
type AssignSubscriptionInput = {
|
| 4 |
-
userId: string;
|
| 5 |
-
subscriptionId: string;
|
| 6 |
-
startDate: string;
|
| 7 |
-
endDate: string;
|
| 8 |
-
};
|
| 9 |
-
|
| 10 |
-
/**
|
| 11 |
-
* Persist a paid subscription after a payment provider's webhook has been
|
| 12 |
-
* authenticated and its payment has been confirmed. Never call this directly
|
| 13 |
-
* from a browser request.
|
| 14 |
-
*/
|
| 15 |
-
export async function assignPaidSubscription(input: AssignSubscriptionInput) {
|
| 16 |
-
const { userId, subscriptionId, startDate, endDate } = input;
|
| 17 |
-
const isoDate = /^\d{4}-\d{2}-\d{2}$/;
|
| 18 |
-
if (!isoDate.test(startDate) || !isoDate.test(endDate) || endDate < startDate) {
|
| 19 |
-
throw new Error("Invalid subscription period.");
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
const { data: plan, error: planError } = await supabaseAdmin
|
| 23 |
-
.from("subscriptions")
|
| 24 |
-
.select("subscription_id")
|
| 25 |
-
.eq("subscription_id", subscriptionId)
|
| 26 |
-
.maybeSingle();
|
| 27 |
-
if (planError) throw new Error(`Unable to validate subscription plan: ${planError.message}`);
|
| 28 |
-
if (!plan) throw new Error("Subscription plan was not found.");
|
| 29 |
-
|
| 30 |
-
const { data, error } = await supabaseAdmin
|
| 31 |
-
.from("users_subscriptions")
|
| 32 |
-
.upsert({ user_id: userId, subscription_id: subscriptionId, startdate: startDate, enddate: endDate }, { onConflict: "user_id" })
|
| 33 |
-
.select("user_id,subscription_id,startdate,enddate")
|
| 34 |
-
.single();
|
| 35 |
-
if (error) throw new Error(`Unable to assign paid subscription: ${error.message}`);
|
| 36 |
-
accessCache.delete(userId);
|
| 37 |
-
accessRequests.delete(userId);
|
| 38 |
-
return data;
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
async function loadActiveSubscription(userId: string) {
|
| 42 |
-
const today = new Date().toISOString().slice(0, 10);
|
| 43 |
-
const { data: assignment, error } = await supabaseAdmin.from("users_subscriptions")
|
| 44 |
-
.select("subscription_id,startdate,enddate").eq("user_id", userId).maybeSingle();
|
| 45 |
-
if (error) throw new Error(`Unable to check subscription: ${error.message}`);
|
| 46 |
-
if (!assignment?.subscription_id || assignment.startdate > today || assignment.enddate < today) return null;
|
| 47 |
-
|
| 48 |
-
const { data: plan, error: planError } = await supabaseAdmin.from("subscriptions")
|
| 49 |
-
.select("subscription_id,plan,price").eq("subscription_id", assignment.subscription_id).maybeSingle();
|
| 50 |
-
if (planError) throw new Error(`Unable to load subscription plan: ${planError.message}`);
|
| 51 |
-
return plan ? { ...plan, startdate: assignment.startdate, enddate: assignment.enddate } : null;
|
| 52 |
-
}
|
| 53 |
-
|
| 54 |
-
// Brief, per-user cache shared by polling, media and session requests.
|
| 55 |
-
const accessCache = new Map<string, { value: Awaited<ReturnType<typeof loadActiveSubscription>>; expires: number }>();
|
| 56 |
-
const accessRequests = new Map<string, Promise<Awaited<ReturnType<typeof loadActiveSubscription>>>>();
|
| 57 |
-
export async function activeSubscriptionFor(userId: string) {
|
| 58 |
-
const cached = accessCache.get(userId);
|
| 59 |
-
if (cached && cached.expires > Date.now()) return cached.value;
|
| 60 |
-
const existing = accessRequests.get(userId);
|
| 61 |
-
if (existing) return existing;
|
| 62 |
-
const pending = loadActiveSubscription(userId).then((value) => {
|
| 63 |
-
if (accessRequests.get(userId) === pending) {
|
| 64 |
-
if (accessCache.size >= 1000) accessCache.delete(accessCache.keys().next().value!);
|
| 65 |
-
const tomorrow = new Date(); tomorrow.setUTCHours(24, 0, 0, 0);
|
| 66 |
-
accessCache.set(userId, { value, expires: Math.min(Date.now() + 30_000, tomorrow.getTime()) });
|
| 67 |
-
}
|
| 68 |
-
return value;
|
| 69 |
-
}).finally(() => { if (accessRequests.get(userId) === pending) accessRequests.delete(userId); });
|
| 70 |
-
accessRequests.set(userId, pending);
|
| 71 |
-
return pending;
|
| 72 |
-
}
|
|
|
|
| 1 |
+
import { supabaseAdmin } from "./supabase.js";
|
| 2 |
+
|
| 3 |
+
type AssignSubscriptionInput = {
|
| 4 |
+
userId: string;
|
| 5 |
+
subscriptionId: string;
|
| 6 |
+
startDate: string;
|
| 7 |
+
endDate: string;
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Persist a paid subscription after a payment provider's webhook has been
|
| 12 |
+
* authenticated and its payment has been confirmed. Never call this directly
|
| 13 |
+
* from a browser request.
|
| 14 |
+
*/
|
| 15 |
+
export async function assignPaidSubscription(input: AssignSubscriptionInput) {
|
| 16 |
+
const { userId, subscriptionId, startDate, endDate } = input;
|
| 17 |
+
const isoDate = /^\d{4}-\d{2}-\d{2}$/;
|
| 18 |
+
if (!isoDate.test(startDate) || !isoDate.test(endDate) || endDate < startDate) {
|
| 19 |
+
throw new Error("Invalid subscription period.");
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const { data: plan, error: planError } = await supabaseAdmin
|
| 23 |
+
.from("subscriptions")
|
| 24 |
+
.select("subscription_id")
|
| 25 |
+
.eq("subscription_id", subscriptionId)
|
| 26 |
+
.maybeSingle();
|
| 27 |
+
if (planError) throw new Error(`Unable to validate subscription plan: ${planError.message}`);
|
| 28 |
+
if (!plan) throw new Error("Subscription plan was not found.");
|
| 29 |
+
|
| 30 |
+
const { data, error } = await supabaseAdmin
|
| 31 |
+
.from("users_subscriptions")
|
| 32 |
+
.upsert({ user_id: userId, subscription_id: subscriptionId, startdate: startDate, enddate: endDate }, { onConflict: "user_id" })
|
| 33 |
+
.select("user_id,subscription_id,startdate,enddate")
|
| 34 |
+
.single();
|
| 35 |
+
if (error) throw new Error(`Unable to assign paid subscription: ${error.message}`);
|
| 36 |
+
accessCache.delete(userId);
|
| 37 |
+
accessRequests.delete(userId);
|
| 38 |
+
return data;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
async function loadActiveSubscription(userId: string) {
|
| 42 |
+
const today = new Date().toISOString().slice(0, 10);
|
| 43 |
+
const { data: assignment, error } = await supabaseAdmin.from("users_subscriptions")
|
| 44 |
+
.select("subscription_id,startdate,enddate").eq("user_id", userId).maybeSingle();
|
| 45 |
+
if (error) throw new Error(`Unable to check subscription: ${error.message}`);
|
| 46 |
+
if (!assignment?.subscription_id || assignment.startdate > today || assignment.enddate < today) return null;
|
| 47 |
+
|
| 48 |
+
const { data: plan, error: planError } = await supabaseAdmin.from("subscriptions")
|
| 49 |
+
.select("subscription_id,plan,price").eq("subscription_id", assignment.subscription_id).maybeSingle();
|
| 50 |
+
if (planError) throw new Error(`Unable to load subscription plan: ${planError.message}`);
|
| 51 |
+
return plan ? { ...plan, startdate: assignment.startdate, enddate: assignment.enddate } : null;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// Brief, per-user cache shared by polling, media and session requests.
|
| 55 |
+
const accessCache = new Map<string, { value: Awaited<ReturnType<typeof loadActiveSubscription>>; expires: number }>();
|
| 56 |
+
const accessRequests = new Map<string, Promise<Awaited<ReturnType<typeof loadActiveSubscription>>>>();
|
| 57 |
+
export async function activeSubscriptionFor(userId: string) {
|
| 58 |
+
const cached = accessCache.get(userId);
|
| 59 |
+
if (cached && cached.expires > Date.now()) return cached.value;
|
| 60 |
+
const existing = accessRequests.get(userId);
|
| 61 |
+
if (existing) return existing;
|
| 62 |
+
const pending = loadActiveSubscription(userId).then((value) => {
|
| 63 |
+
if (accessRequests.get(userId) === pending) {
|
| 64 |
+
if (accessCache.size >= 1000) accessCache.delete(accessCache.keys().next().value!);
|
| 65 |
+
const tomorrow = new Date(); tomorrow.setUTCHours(24, 0, 0, 0);
|
| 66 |
+
accessCache.set(userId, { value, expires: Math.min(Date.now() + 30_000, tomorrow.getTime()) });
|
| 67 |
+
}
|
| 68 |
+
return value;
|
| 69 |
+
}).finally(() => { if (accessRequests.get(userId) === pending) accessRequests.delete(userId); });
|
| 70 |
+
accessRequests.set(userId, pending);
|
| 71 |
+
return pending;
|
| 72 |
+
}
|
start.py
CHANGED
|
@@ -1,55 +1,55 @@
|
|
| 1 |
-
"""Run the public Node API and private Python worker as one service."""
|
| 2 |
-
import os
|
| 3 |
-
from pathlib import Path
|
| 4 |
-
import signal
|
| 5 |
-
import subprocess
|
| 6 |
-
import sys
|
| 7 |
-
import time
|
| 8 |
-
|
| 9 |
-
from dotenv import load_dotenv
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def main():
|
| 13 |
-
root = Path(__file__).resolve().parent
|
| 14 |
-
load_dotenv(root / ".env")
|
| 15 |
-
port = int(os.environ.get("PORT", "4000"))
|
| 16 |
-
if not 1 <= port <= 65535 or port == 8000:
|
| 17 |
-
raise ValueError("PORT must be between 1 and 65535 and differ from AI port 8000")
|
| 18 |
-
env = dict(os.environ, PORT=str(port),
|
| 19 |
-
ANALYSIS_API_URL="http://127.0.0.1:8000",
|
| 20 |
-
BACKEND_INTERNAL_URL=f"http://127.0.0.1:{port}")
|
| 21 |
-
children = []
|
| 22 |
-
stopping = False
|
| 23 |
-
|
| 24 |
-
def stop(signum, frame):
|
| 25 |
-
nonlocal stopping
|
| 26 |
-
stopping = True
|
| 27 |
-
|
| 28 |
-
for sig in (signal.SIGINT, signal.SIGTERM):
|
| 29 |
-
signal.signal(sig, stop)
|
| 30 |
-
try:
|
| 31 |
-
children.append(subprocess.Popen([sys.executable, "-m", "uvicorn", "app:app",
|
| 32 |
-
"--host", "127.0.0.1", "--port", "8000", "--workers", "1"], cwd=root, env=env))
|
| 33 |
-
children.append(subprocess.Popen(["node", "dist/server.js"], cwd=root, env=env))
|
| 34 |
-
while not stopping:
|
| 35 |
-
for child in children:
|
| 36 |
-
if child.poll() is not None:
|
| 37 |
-
print(f"Backend process {child.pid} exited ({child.returncode}); stopping service", flush=True)
|
| 38 |
-
return child.returncode or 1
|
| 39 |
-
time.sleep(0.25)
|
| 40 |
-
return 0
|
| 41 |
-
finally:
|
| 42 |
-
for child in children:
|
| 43 |
-
if child.poll() is None:
|
| 44 |
-
child.terminate()
|
| 45 |
-
deadline = time.monotonic() + 15
|
| 46 |
-
for child in children:
|
| 47 |
-
try:
|
| 48 |
-
child.wait(timeout=max(0, deadline - time.monotonic()))
|
| 49 |
-
except subprocess.TimeoutExpired:
|
| 50 |
-
child.kill()
|
| 51 |
-
child.wait()
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
if __name__ == "__main__":
|
| 55 |
-
sys.exit(main())
|
|
|
|
| 1 |
+
"""Run the public Node API and private Python worker as one service."""
|
| 2 |
+
import os
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import signal
|
| 5 |
+
import subprocess
|
| 6 |
+
import sys
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def main():
|
| 13 |
+
root = Path(__file__).resolve().parent
|
| 14 |
+
load_dotenv(root / ".env")
|
| 15 |
+
port = int(os.environ.get("PORT", "4000"))
|
| 16 |
+
if not 1 <= port <= 65535 or port == 8000:
|
| 17 |
+
raise ValueError("PORT must be between 1 and 65535 and differ from AI port 8000")
|
| 18 |
+
env = dict(os.environ, PORT=str(port),
|
| 19 |
+
ANALYSIS_API_URL="http://127.0.0.1:8000",
|
| 20 |
+
BACKEND_INTERNAL_URL=f"http://127.0.0.1:{port}")
|
| 21 |
+
children = []
|
| 22 |
+
stopping = False
|
| 23 |
+
|
| 24 |
+
def stop(signum, frame):
|
| 25 |
+
nonlocal stopping
|
| 26 |
+
stopping = True
|
| 27 |
+
|
| 28 |
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
| 29 |
+
signal.signal(sig, stop)
|
| 30 |
+
try:
|
| 31 |
+
children.append(subprocess.Popen([sys.executable, "-m", "uvicorn", "app:app",
|
| 32 |
+
"--host", "127.0.0.1", "--port", "8000", "--workers", "1"], cwd=root, env=env))
|
| 33 |
+
children.append(subprocess.Popen(["node", "dist/server.js"], cwd=root, env=env))
|
| 34 |
+
while not stopping:
|
| 35 |
+
for child in children:
|
| 36 |
+
if child.poll() is not None:
|
| 37 |
+
print(f"Backend process {child.pid} exited ({child.returncode}); stopping service", flush=True)
|
| 38 |
+
return child.returncode or 1
|
| 39 |
+
time.sleep(0.25)
|
| 40 |
+
return 0
|
| 41 |
+
finally:
|
| 42 |
+
for child in children:
|
| 43 |
+
if child.poll() is None:
|
| 44 |
+
child.terminate()
|
| 45 |
+
deadline = time.monotonic() + 15
|
| 46 |
+
for child in children:
|
| 47 |
+
try:
|
| 48 |
+
child.wait(timeout=max(0, deadline - time.monotonic()))
|
| 49 |
+
except subprocess.TimeoutExpired:
|
| 50 |
+
child.kill()
|
| 51 |
+
child.wait()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
if __name__ == "__main__":
|
| 55 |
+
sys.exit(main())
|