anicove / api /models /watch_together.py
mwask's picture
Upload 124 files
19ecc0d verified
Raw
History Blame Contribute Delete
16.6 kB
from datetime import datetime, timedelta, timezone
import secrets
import re
from markupsafe import escape
from pymongo import ASCENDING, DESCENDING, ReturnDocument
from ..core.db_connector import (
watch_together_rooms_collection,
watch_together_messages_collection,
)
ROOM_TTL_SECONDS = 6 * 60 * 60
MEMBER_TTL_SECONDS = 90
MAX_CHAT_MESSAGES = 200
MAX_ROOM_MEMBERS = 20
ROOM_ID_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_indexes_ready = False
def utcnow():
return datetime.now(timezone.utc)
def iso(dt):
if not dt:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat()
def epoch(dt):
if not dt:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.timestamp()
def clean_room_id(room_id):
return re.sub(r"[^A-Z0-9]", "", str(room_id or "").upper())[:12]
def clean_client_id(client_id):
raw = str(client_id or "").strip()
if re.fullmatch(r"[A-Za-z0-9_-]{8,64}", raw):
return raw
return secrets.token_urlsafe(16)
def clean_display_name(name, fallback="Guest"):
cleaned = re.sub(r"\s+", " ", str(name or "")).strip()
cleaned = re.sub(r"[^\w\s\-\.\(\)\[\]]", "", cleaned) # Basic anti-XSS/malformed
return (cleaned or fallback)[:32]
def room_expiry(now=None):
now = now or utcnow()
return now + timedelta(seconds=ROOM_TTL_SECONDS)
def _ensure_indexes():
global _indexes_ready
if _indexes_ready:
return
try:
watch_together_rooms_collection.create_index(
[("expires_at", ASCENDING)],
expireAfterSeconds=0,
name="watch_together_room_ttl",
)
watch_together_rooms_collection.create_index(
[("room_id", ASCENDING)],
unique=True,
name="watch_together_room_id",
)
watch_together_messages_collection.create_index(
[("expires_at", ASCENDING)],
expireAfterSeconds=0,
name="watch_together_message_ttl",
)
watch_together_messages_collection.create_index(
[("room_id", ASCENDING), ("seq", DESCENDING)],
name="watch_together_room_messages",
)
except Exception:
pass
_indexes_ready = True
def new_room_id():
_ensure_indexes()
while True:
room_id = "".join(secrets.choice(ROOM_ID_ALPHABET) for _ in range(8))
if not watch_together_rooms_collection.find_one({"room_id": room_id}, {"_id": 1}):
return room_id
def _member_doc(client_id, display_name, avatar=None, user_id=None, now=None):
now = now or utcnow()
return {
"id": client_id,
"name": clean_display_name(display_name),
"avatar": avatar,
"user_id": user_id or "",
"joined_at": now,
"last_seen": now,
}
def _serialize_member(member, host_id=None, client_id=None):
member_id = member.get("id")
return {
"id": member_id,
"name": member.get("name") or "Guest",
"avatar": member.get("avatar"),
"user_id": member.get("user_id") or "",
"is_host": member_id == host_id,
"is_self": member_id == client_id,
"last_seen": iso(member.get("last_seen")),
}
def _serialize_playback(playback):
playback = playback or {}
return {
"paused": bool(playback.get("paused", True)),
"position": float(playback.get("position", 0) or 0),
"rate": float(playback.get("rate", 1) or 1),
"duration": float(playback.get("duration", 0) or 0),
"seq": int(playback.get("seq", 0) or 0),
"event": playback.get("event") or "sync",
"updated_by": playback.get("updated_by"),
"updated_by_name": playback.get("updated_by_name"),
"updated_at": epoch(playback.get("updated_at")),
}
def serialize_message(message):
return {
"seq": int(message.get("seq", 0) or 0),
"author": message.get("author") or "Guest",
"author_id": message.get("author_id"),
"avatar": message.get("avatar"),
"body": message.get("body") or "",
"created_at": iso(message.get("created_at")),
}
def serialize_room(room, client_id=None, messages=None):
if not room:
return None
host_id = room.get("host_id")
members = [
_serialize_member(member, host_id, client_id)
for member in (room.get("members") or {}).values()
if member.get("id")
]
members.sort(key=lambda item: (not item["is_host"], item["name"].lower()))
return {
"room_id": room.get("room_id"),
"anime_id": room.get("anime_id"),
"episode_number": room.get("episode_number"),
"language": room.get("language") or "sub",
"provider": room.get("provider"),
"hls_providers": room.get("hls_providers") or [],
"anime_title": room.get("anime_title") or "",
"episode_title": room.get("episode_title") or "",
"poster": room.get("poster") or "",
"episode_image": room.get("episode_image") or "",
"created_at": iso(room.get("created_at")),
"updated_at": iso(room.get("updated_at")),
"expires_at": iso(room.get("expires_at")),
"host_id": host_id,
"state_seq": int(room.get("state_seq", 0) or 0),
"chat_seq": int(room.get("chat_seq", 0) or 0),
"playback": _serialize_playback(room.get("playback")),
"members": members,
"messages": messages or [],
}
def create_room(
anime_id,
episode_number,
language,
provider,
hls_providers,
creator,
metadata=None,
):
_ensure_indexes()
now = utcnow()
expires_at = room_expiry(now)
room_id = new_room_id()
client_id = clean_client_id(creator.get("client_id"))
member = _member_doc(
client_id,
creator.get("display_name"),
creator.get("avatar"),
user_id=creator.get("user_id") or "",
now=now,
)
metadata = metadata or {}
doc = {
"room_id": room_id,
"anime_id": str(anime_id),
"episode_number": int(episode_number),
"language": language if language in ("sub", "dub") else "sub",
"provider": provider,
"hls_providers": hls_providers,
"anime_title": metadata.get("anime_title") or "",
"episode_title": metadata.get("episode_title") or "",
"poster": metadata.get("poster") or "",
"episode_image": metadata.get("episode_image") or "",
"anilist_id": metadata.get("anilist_id"),
"mal_id": metadata.get("mal_id"),
"host_id": client_id,
"members": {client_id: member},
"playback": {
"paused": True,
"position": 0.0,
"rate": 1.0,
"duration": 0.0,
"seq": 1,
"event": "created",
"updated_by": client_id,
"updated_by_name": member["name"],
"updated_at": now,
},
"state_seq": 1,
"chat_seq": 0,
"created_at": now,
"updated_at": now,
"expires_at": expires_at,
}
watch_together_rooms_collection.insert_one(doc)
return doc
def get_room(room_id):
_ensure_indexes()
clean_id = clean_room_id(room_id)
return watch_together_rooms_collection.find_one(
{"room_id": clean_id, "expires_at": {"$gt": utcnow()}}
)
def touch_room(room_id, client_id=None, display_name=None, avatar=None, user_id=None, extend_messages=False):
room = get_room(room_id)
if not room:
return None
now = utcnow()
expires_at = room_expiry(now)
update = {
"updated_at": now,
"expires_at": expires_at,
}
unset = {}
if client_id:
client_id = clean_client_id(client_id)
existing = (room.get("members") or {}).get(client_id, {})
# Check member limit for NEW members
if not existing and len(room.get("members") or {}) >= MAX_ROOM_MEMBERS:
return room
# Deduplicate logged-in users: remove old member entries with same
# user_id but different client_id so the same account only appears once.
if user_id:
for mid, member in (room.get("members") or {}).items():
if mid != client_id and member.get("user_id") == user_id:
unset[f"members.{mid}"] = ""
# If the host entry is being replaced, migrate host_id
if room.get("host_id") == mid:
update["host_id"] = client_id
else:
# Deduplicate guest users by display name to handle refreshes on browsers/modes with no localStorage persistence
cleaned_name = clean_display_name(display_name)
for mid, member in (room.get("members") or {}).items():
if mid != client_id and not member.get("user_id") and clean_display_name(member.get("name")) == cleaned_name:
unset[f"members.{mid}"] = ""
# If the host entry is being replaced, migrate host_id
if room.get("host_id") == mid:
update["host_id"] = client_id
update[f"members.{client_id}"] = {
"id": client_id,
"name": clean_display_name(display_name or existing.get("name")),
"avatar": avatar if avatar is not None else existing.get("avatar"),
"user_id": user_id or existing.get("user_id") or "",
"joined_at": existing.get("joined_at", now),
"last_seen": now,
}
mongo_ops = {"$set": update}
if unset:
mongo_ops["$unset"] = unset
room = watch_together_rooms_collection.find_one_and_update(
{"room_id": room["room_id"]},
mongo_ops,
return_document=ReturnDocument.AFTER,
)
if extend_messages:
watch_together_messages_collection.update_many(
{"room_id": room["room_id"]},
{"$set": {"expires_at": expires_at}},
)
return prune_members(room)
def prune_members(room):
if not room:
return None
now = utcnow()
stale = []
for member_id, member in (room.get("members") or {}).items():
last_seen = member.get("last_seen") or room.get("created_at") or now
if last_seen.tzinfo is None:
last_seen = last_seen.replace(tzinfo=timezone.utc)
if (now - last_seen).total_seconds() > MEMBER_TTL_SECONDS:
stale.append(member_id)
if stale:
unset = {f"members.{member_id}": "" for member_id in stale}
room = watch_together_rooms_collection.find_one_and_update(
{"room_id": room["room_id"]},
{"$unset": unset},
return_document=ReturnDocument.AFTER,
)
members = room.get("members") or {}
if room.get("host_id") not in members and members:
next_host = sorted(
members.values(),
key=lambda item: item.get("joined_at") or now,
)[0]["id"]
room = watch_together_rooms_collection.find_one_and_update(
{"room_id": room["room_id"]},
{"$set": {"host_id": next_host}},
return_document=ReturnDocument.AFTER,
)
return room
def leave_room(room_id, client_id):
room = get_room(room_id)
if not room:
return None
client_id = clean_client_id(client_id)
room = watch_together_rooms_collection.find_one_and_update(
{"room_id": room["room_id"]},
{
"$unset": {f"members.{client_id}": ""},
"$set": {"updated_at": utcnow(), "expires_at": room_expiry()},
},
return_document=ReturnDocument.AFTER,
)
return prune_members(room)
def get_messages(room_id, since_seq=0, limit=MAX_CHAT_MESSAGES):
_ensure_indexes()
query = {"room_id": clean_room_id(room_id)}
try:
since_seq = int(since_seq or 0)
except (TypeError, ValueError):
since_seq = 0
if since_seq:
query["seq"] = {"$gt": since_seq}
sort = [("seq", ASCENDING)]
else:
sort = [("seq", DESCENDING)]
messages = list(
watch_together_messages_collection.find(query).sort(sort).limit(limit)
)
if not since_seq:
messages.reverse()
return [serialize_message(message) for message in messages]
def add_chat_message(room, client_id, display_name, avatar, body):
body = re.sub(r"\s+", " ", str(body or "")).strip()
if not body:
return room, None
if len(body) > 500:
body = body[:500]
# Securely escape HTML entities
body = str(escape(body))
now = utcnow()
expires_at = room_expiry(now)
room = watch_together_rooms_collection.find_one_and_update(
{"room_id": room["room_id"]},
{
"$inc": {"chat_seq": 1, "state_seq": 1},
"$set": {"updated_at": now, "expires_at": expires_at},
},
return_document=ReturnDocument.AFTER,
)
seq = int(room.get("chat_seq", 0) or 0)
message = {
"room_id": room["room_id"],
"seq": seq,
"author_id": clean_client_id(client_id),
"author": clean_display_name(display_name),
"avatar": avatar,
"body": body,
"created_at": now,
"expires_at": expires_at,
}
watch_together_messages_collection.insert_one(message)
return room, serialize_message(message)
def update_playback(room, client_id, display_name, payload, event_type):
now = utcnow()
try:
position = max(0.0, float(payload.get("position", 0) or 0))
except (TypeError, ValueError):
position = 0.0
try:
duration = max(0.0, float(payload.get("duration", 0) or 0))
except (TypeError, ValueError):
duration = 0.0
try:
rate = float(payload.get("rate", 1) or 1)
except (TypeError, ValueError):
rate = 1.0
if rate <= 0 or rate > 4:
rate = 1.0
paused = bool(payload.get("paused", event_type != "play"))
if event_type == "play":
paused = False
elif event_type == "pause":
paused = True
update = {
"playback": {
"paused": paused,
"position": position,
"rate": rate,
"duration": duration,
"seq": int(room.get("playback", {}).get("seq", 0) or 0) + 1,
"event": event_type,
"updated_by": clean_client_id(client_id),
"updated_by_name": clean_display_name(display_name),
"updated_at": now,
},
"updated_at": now,
"expires_at": room_expiry(now),
}
return watch_together_rooms_collection.find_one_and_update(
{"room_id": room["room_id"]},
{"$inc": {"state_seq": 1}, "$set": update},
return_document=ReturnDocument.AFTER,
)
def update_provider(room, provider, client_id, display_name, payload=None):
if provider not in (room.get("hls_providers") or []):
return None
payload = payload or {}
now = utcnow()
playback = dict(room.get("playback") or {})
if "position" in payload:
try:
playback["position"] = max(0.0, float(payload.get("position") or 0))
except (TypeError, ValueError):
pass
if "duration" in payload:
try:
playback["duration"] = max(0.0, float(payload.get("duration") or 0))
except (TypeError, ValueError):
pass
playback["seq"] = int(playback.get("seq", 0) or 0) + 1
playback["event"] = "server_change"
playback["updated_by"] = clean_client_id(client_id)
playback["updated_by_name"] = clean_display_name(display_name)
playback["updated_at"] = now
return watch_together_rooms_collection.find_one_and_update(
{"room_id": room["room_id"]},
{
"$inc": {"state_seq": 1},
"$set": {
"provider": provider,
"playback": playback,
"updated_at": now,
"expires_at": room_expiry(now),
},
},
return_document=ReturnDocument.AFTER,
)