initial commit

This commit is contained in:
2025-07-20 00:51:55 +03:00
commit 42684e0cb6
29 changed files with 2615 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
venv
.venv
__pycache__
.idea
.nicegui
data
config.py
+7
View File
@@ -0,0 +1,7 @@
from movies.db import MoviesDB
from rooms.db import RoomsDB
from users.db import UsersDB
MOVIES_DATABASE: MoviesDB | None = None
USERS_DATABASE: UsersDB | None = None
ROOMS_DATABASE: RoomsDB | None = None
+67
View File
@@ -0,0 +1,67 @@
import asyncio
import logging
import os
import pathlib
from nicegui import background_tasks, app
import config
import globals
import web
from movies.db import MoviesDB
from rooms.db import RoomsDB
from users.db import UsersDB
working_dir = pathlib.Path(__file__).resolve().parent
os.chdir(working_dir)
if config.ENABLE_LOGGING:
logging.basicConfig(level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
async def init_movies_db():
globals.MOVIES_DATABASE = MoviesDB(db_path="data/movies_db.json")
await globals.MOVIES_DATABASE.load_from_disk()
await globals.MOVIES_DATABASE.update()
async def init_users_db():
globals.USERS_DATABASE = UsersDB(db_path="data/users_db.json")
await globals.USERS_DATABASE.load_from_disk()
await globals.USERS_DATABASE.remove_inactive()
async def init_rooms_db():
globals.ROOMS_DATABASE = RoomsDB()
async def before_startup():
if not os.path.exists("data"):
os.mkdir("data")
await init_movies_db()
await init_users_db()
await init_rooms_db()
async def after_startup():
background_tasks.create_lazy(globals.MOVIES_DATABASE.auto_update(), name="movies_db_auto_update")
background_tasks.create_lazy(globals.USERS_DATABASE.auto_remove_inactive(), name="users_db_auto_remove_inactive")
logging.info("Application successfully started!")
asyncio.run(before_startup())
app.on_startup(after_startup())
web.ui.run(
host=config.HOST,
port=config.PORT,
dark=config.USE_DARK_THEME,
reload=False,
show=False,
storage_secret=config.SECRET
)
+74
View File
@@ -0,0 +1,74 @@
from dataclasses import dataclass
from typing import Sized
@dataclass(frozen=True)
class Content:
tmdb_id: int = None
vote_average: float = None
adult: bool = None
title: str = None
og_title: str = None
homepage: str = None
poster_url: str = None
backdrop_url: str = None
release_date: str = None
genres: tuple[Sized, ...] = None
@dataclass(frozen=True)
class Movie(Content):
type: str = "movie"
file_size: int = None
budget: int = None
runtime: int = None
file_url: str = None
@dataclass(frozen=True)
class Episode:
type: str = "episode"
episode_number: int = None
season_number: int = None
runtime: int = None
vote_average: float = None
title: str = None
file_url: str = None
episode_type: str = None
release_date: str = None
@dataclass(frozen=True)
class Season:
type: str = "season"
season_number: int = None
episodes_count: int = None
vote_average: float = None
title: str = None
poster_url: str = None
release_date: str = None
episodes: tuple[Episode, ...] = None
@dataclass(frozen=True)
class TVShow(Content):
type: str = "tv"
number_of_episodes: int = None
number_of_seasons: int = None
in_production: bool = None
seasons: tuple[Season, ...] = None
+95
View File
@@ -0,0 +1,95 @@
import asyncio
import logging
from datetime import datetime
from pathlib import Path
import aiofiles
import orjson
import config
import movies.tmdb as tmdb
import movies.yandex_disk as yandex_disk
from movies.classes import Movie, TVShow
from singleton import Singleton
class MoviesDB(metaclass=Singleton):
def __init__(self, db_path: Path | str):
self.path = Path(db_path).resolve()
self.contents = []
self.by_tmdb_id: dict[int, Movie | TVShow] = {}
self.by_title: dict[str, Movie | TVShow] = {}
self.last_updated = None
def _assign_content(self):
self.by_tmdb_id = {}
self.by_title = {}
for content in self.contents:
tmdb_id = content.tmdb_id
self.by_tmdb_id[tmdb_id] = content
title, og_title = content.title, content.og_title
title = title if title else ""
og_title = og_title if og_title else ""
full_title = title + og_title
if full_title:
self.by_title[full_title] = content
async def auto_update(self):
while True:
await asyncio.sleep(config.MOVIES_DB_UPDATE_INTERVAL_SECONDS)
await self.update()
async def update(self):
logging.info("Updating Movies DB")
raw_contents = await yandex_disk.get_all_contents()
self.contents = await tmdb.fetch_all_data(raw_contents)
self.last_updated = datetime.now()
await self.save_to_disk()
self._assign_content()
logging.info("Finished updating Movies DB")
async def save_to_disk(self):
logging.info("Saving Movies DB to disk")
to_save = await asyncio.to_thread(orjson.dumps, {
"last_updated": self.last_updated,
"contents": self.contents
})
async with aiofiles.open(self.path, "wb") as file:
await file.write(to_save)
logging.info("Finished Saving Movies DB to disk")
async def load_from_disk(self):
if not self.path.exists():
return
logging.info("Loading Movies DB from disk")
async with aiofiles.open(self.path, "rb") as file:
file_content = await file.read()
if len(file_content) == 0:
return
loaded_db = await asyncio.to_thread(orjson.loads, file_content)
self.last_updated = loaded_db.get("last_updated", None)
contents = loaded_db.get("contents", [])
new_contents = []
for content in contents:
match content["type"]:
case "movie":
new_contents.append(Movie(**content))
case "tv":
new_contents.append(TVShow(**content))
case _:
continue
self.contents = new_contents
self._assign_content()
logging.info("Finished Loading Movies DB from disk")
+210
View File
@@ -0,0 +1,210 @@
import asyncio
import copy
import logging
import random
import aiohttp
from cachetools import TTLCache
from cachetools_async import cached
import config
from movies.classes import Movie, TVShow, Season, Episode
_BASE_API_URL = "https://api.themoviedb.org/3"
_BASE_IMAGE_URL = None
@cached(TTLCache(maxsize=config.CACHE_MAXSIZE, ttl=config.CACHE_TTL))
async def _fetch_tmdb_data():
global _BASE_IMAGE_URL
logging.info("Fetching TMDB configuration data")
headers = {
"Authorization": f"Bearer {config.TMDB_API_KEY}",
"Accept": "application/json"
}
proxy = random.choice(config.PROXIES) if config.PROXIES else None
async with aiohttp.ClientSession(proxy=proxy) as session:
async with session.get(
url=f"{_BASE_API_URL}/configuration",
headers=headers,
params={"language": config.TMDB_LANG}
) as response:
assert response.status == 200, "Failed to fetch TMDB configuration data"
response_json = await response.json()
_BASE_IMAGE_URL = response_json["images"]["base_url"]
@cached(TTLCache(maxsize=config.CACHE_MAXSIZE, ttl=config.CACHE_TTL))
async def _fetch_movie(movie: Movie) -> Movie:
headers = {
"Authorization": f"Bearer {config.TMDB_API_KEY}",
"Accept": "application/json"
}
proxy = random.choice(config.PROXIES) if config.PROXIES else None
async with aiohttp.ClientSession(proxy=proxy) as session:
async with session.get(
url=f"{_BASE_API_URL}/movie/{movie.tmdb_id}",
headers=headers,
params={"language": config.TMDB_LANG}
) as response:
assert response.status == 200, f"Failed to fetch information for {movie.tmdb_id}"
response_json = await response.json()
return Movie(
tmdb_id=movie.tmdb_id,
file_size=movie.file_size,
file_url=movie.file_url,
title=response_json.get("title", movie.title),
budget=response_json.get("budget"),
runtime=response_json.get("runtime"),
vote_average=response_json.get("vote_average"),
adult=response_json.get("adult"),
og_title=response_json.get("original_title"),
homepage=response_json.get("homepage"),
poster_url=f"{_BASE_IMAGE_URL}original{response_json["poster_path"]}" if response_json.get(
"poster_path") else None,
backdrop_url=f"{_BASE_IMAGE_URL}original{response_json["backdrop_path"]}" if response_json.get(
"backdrop_path") else None,
release_date=response_json.get("release_date"),
genres=tuple(sorted([genre["name"].capitalize() for genre in response_json.get("genres", [])], key=len)),
)
@cached(TTLCache(maxsize=config.CACHE_MAXSIZE, ttl=config.CACHE_TTL))
async def _fetch_tv_show(tv_show: TVShow) -> TVShow:
headers = {
"Authorization": f"Bearer {config.TMDB_API_KEY}",
"Accept": "application/json"
}
proxy = random.choice(config.PROXIES) if config.PROXIES else None
async with aiohttp.ClientSession(proxy=proxy) as session:
async with session.get(
url=f"{_BASE_API_URL}/tv/{tv_show.tmdb_id}",
headers=headers,
params={"language": config.TMDB_LANG}
) as tv_response:
assert tv_response.status == 200, f"Failed to fetch information for {tv_show.tmdb_id}"
tv_response_json = await tv_response.json()
seasons_responses_jsons = {}
for i in range(len(tv_show.seasons)):
season_number = tv_show.seasons[i].season_number
async with session.get(
url=f"{_BASE_API_URL}/tv/{tv_show.tmdb_id}/season/{season_number}",
headers=headers,
params={"language": config.TMDB_LANG}
) as season_response:
assert tv_response.status == 200, f"Failed to fetch information for {tv_show.tmdb_id}"
seasons_responses_jsons[season_number] = await season_response.json()
seasons_dict = {}
for season_number, season_response_json in seasons_responses_jsons.items():
for episode_response_json in season_response_json.get("episodes", []):
episode_number = episode_response_json["episode_number"]
raw_season = None
for season in tv_show.seasons:
if season.season_number == season_number:
raw_season = season
break
if raw_season is None:
continue
raw_episode = None
for episode in raw_season.episodes:
if episode.episode_number == episode_number:
raw_episode = episode
break
if raw_episode is None:
continue
if not seasons_dict.get(season_number):
seasons_dict[season_number] = []
episode = Episode(
episode_number=episode_number,
season_number=season_number,
runtime=episode_response_json.get("runtime"),
vote_average=episode_response_json.get("vote_average"),
title=episode_response_json.get("name", str(episode_number)),
file_url=raw_episode.file_url,
episode_type=episode_response_json.get("episode_type"),
release_date=episode_response_json.get("air_date"),
)
seasons_dict[season_number].append(episode)
seasons = []
for season_number, season_response_json in seasons_responses_jsons.items():
raw_season = None
for season in tv_show.seasons:
if season.season_number == season_number:
raw_season = season
break
if raw_season is None:
continue
season = Season(
season_number=season_number,
episodes_count=len(seasons_dict[season_number]),
vote_average=season_response_json.get("vote_average"),
title=season_response_json.get("name", str(season_number)),
poster_url=f"{_BASE_IMAGE_URL}original{season_response_json["poster_path"]}" if season_response_json.get(
"poster_path") else None,
release_date=season_response_json.get("air_date"),
episodes=tuple(copy.deepcopy(seasons_dict[season_number]))
)
seasons.append(season)
return TVShow(
tmdb_id=tv_show.tmdb_id,
number_of_episodes=tv_show.number_of_episodes,
number_of_seasons=len(seasons),
vote_average=tv_response_json.get("vote_average"),
adult=tv_response_json.get("adult"),
title=tv_response_json.get("name"),
og_title=tv_response_json.get("original_name"),
homepage=tv_response_json.get("homepage"),
poster_url=f"{_BASE_IMAGE_URL}original{tv_response_json["poster_path"]}" if tv_response_json.get(
"poster_path") else None,
backdrop_url=f"{_BASE_IMAGE_URL}original{tv_response_json["backdrop_path"]}" if tv_response_json.get(
"backdrop_path") else None,
release_date=tv_response_json.get("first_air_date"),
in_production=tv_response_json.get("production"),
seasons=tuple(seasons),
genres=tuple(sorted([genre["name"].capitalize() for genre in tv_response_json.get("genres", [])], key=len)),
)
async def _fetch_data(content: Movie | TVShow) -> Movie | TVShow:
logging.info("Fetching data for %s", content.tmdb_id)
match content.type:
case "movie":
return await _fetch_movie(content)
case "tv":
return await _fetch_tv_show(content)
case _:
raise TypeError(f"Unknown content type: {content.type}")
async def fetch_all_data(contents: list[Movie | TVShow]) -> list[Movie | TVShow]:
logging.info("Fetching data for %s contents", len(contents))
await _fetch_tmdb_data()
tasks = [_fetch_data(content) for content in contents]
return await asyncio.gather(*tasks)
+104
View File
@@ -0,0 +1,104 @@
import asyncio
import copy
import itertools
import logging
from cachetools import TTLCache
from cachetools_async import cached
from yndx_disk.classes import Directory
from yndx_disk.clients import AsyncDiskClient
import config
from movies.classes import Movie, TVShow, Season, Episode
@cached(TTLCache(maxsize=config.CACHE_MAXSIZE, ttl=config.CACHE_TTL))
async def _parse_tv_show(disk_client: AsyncDiskClient, directory: Directory) -> TVShow:
file_type, tmdb_id, name = map(str.strip, directory.name.split("#"))
logging.info(f"Parsing TV show %s", tmdb_id)
seasons_dict = {}
contents = await disk_client.listdir(path=directory.path, limit=10000)
for obj in contents:
if type(obj) is Directory:
continue
obj_name, obj_extension = map(str.strip, obj.name.split("."))
season_number, episode_number = map(str.strip, obj_name.split("#"))
if not seasons_dict.get(season_number):
seasons_dict[season_number] = []
episode = Episode(
episode_number=int(episode_number),
season_number=int(season_number),
file_url=obj.file_url
)
seasons_dict[season_number].append(episode)
seasons = []
for season_number, episodes in seasons_dict.items():
season = Season(
season_number=int(season_number),
episodes_count=len(episodes),
episodes=tuple(copy.deepcopy(episodes))
)
seasons.append(season)
return TVShow(
tmdb_id=int(tmdb_id),
number_of_episodes=len(list(itertools.chain.from_iterable([s.episodes for s in seasons]))),
number_of_seasons=len(seasons),
title=name,
seasons=tuple(seasons)
)
@cached(TTLCache(maxsize=config.CACHE_MAXSIZE, ttl=config.CACHE_TTL))
async def _get_contents_on_disk(token: str, path: str) -> list[Movie]:
logging.info("Fetching contents from disk ...%s", token[-10:])
disk_client = AsyncDiskClient(token=token, auto_update_info=False)
files = await disk_client.listdir(path, limit=10000)
logging.info("Found %s files on disk ...%s", len(files), token[-10:])
movies = []
tv_shows = []
for obj in files:
file_type, tmdb_id, name = map(str.strip, obj.name.split("#"))
match file_type:
case "movie":
name, extension = map(str.strip, name.split("."))
movie = Movie(
tmdb_id=int(tmdb_id),
file_size=int(obj.size),
file_url=obj.file_url,
title=name
)
movies.append(movie)
case "tv":
pass
tv_show = await _parse_tv_show(disk_client=disk_client, directory=obj)
tv_shows.append(tv_show)
case _:
continue
logging.info("Found %s contents on disk ...%s", len(movies), token[-10:])
return movies + tv_shows
async def get_all_contents() -> list[Movie]:
logging.info("Fetching all contents on all disks")
tasks = [_get_contents_on_disk(token, path) for token, path in config.YANDEX_CONFIGS]
movies = await asyncio.gather(*tasks)
movies = list(itertools.chain.from_iterable(movies))
logging.info("Found %s contents on all disks", len(movies))
return movies
+16
View File
@@ -0,0 +1,16 @@
[project]
name = "watch-with-friends"
version = "0.1.0"
description = "Self-hosted web-app that let's you watch movides together using Yandex.Disk"
requires-python = ">=3.12"
dependencies = [
"nicegui>=2.21.1",
"aiohttp>=3.12.14",
"aiofiles>=24.1.0",
"yndx-disk>=0.3.2",
"cachetools>=6.1.0",
"cachetools-async>=0.0.5",
"orjson>=3.11.0",
"bcrypt>=4.3.0",
"pyjwt>=2.10.1",
]
+39
View File
@@ -0,0 +1,39 @@
import logging
from nicegui import background_tasks
from rooms.room import Room
from rooms.utils import generate_uid
class RoomsDB:
def __init__(self):
self.rooms = []
self.by_uid: dict[str, Room] = {}
async def create_room(self, tmdb_id: int) -> Room:
logging.info("Creating new room")
uid = generate_uid()
while uid in self.by_uid.keys():
uid = generate_uid()
room = Room(uid=uid, tmdb_id=tmdb_id)
self.rooms.append(room)
self.by_uid[uid] = room
background_tasks.create_lazy(room.update(), name=f"room_update_{uid}")
logging.info(f"Created new room {room.uid}")
return room
async def delete_room(self, uid: str):
logging.info(f"Deleting room {uid}")
background_tasks.lazy_tasks_running.get(f"room_update_{uid}").cancel()
room = self.by_uid[uid]
del self.by_uid[uid]
self.rooms.remove(room)
logging.info(f"Deleted room {uid}")
+82
View File
@@ -0,0 +1,82 @@
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime
import config
import globals
from rooms.state import PlayerState
@dataclass(frozen=False)
class Room:
uid: str
tmdb_id: int
player_position: float = 0.0
player_state: PlayerState = PlayerState.PAUSED
current_season: int | None = None
current_episode: int | None = None
connected_users: list[str] = field(default_factory=list)
def __init__(self, uid: str, tmdb_id: int):
self.uid = uid
self.tmdb_id = tmdb_id
self.player_position = 0.0
self.player_state = PlayerState.PAUSED
self.current_season = None
self.current_episode = None
self.connected_users = []
content = globals.MOVIES_DATABASE.by_tmdb_id[tmdb_id]
if content.type == "tv":
self.current_season = 1
self.current_episode = 1
self.last_update = datetime.now()
async def _delete(self):
await globals.ROOMS_DATABASE.delete_room(self.uid)
async def _update(self):
if not self.connected_users:
for _ in range(100):
await asyncio.sleep(0.1)
if self.connected_users:
break
if not self.connected_users:
logging.info("No users connected to %s, deleting it", self.uid)
await self._delete()
now = datetime.now()
if self.player_state == PlayerState.PLAYING:
self.player_position += (now - self.last_update).total_seconds()
self.last_update = now
async def update(self):
self.last_update = datetime.now()
while True:
await asyncio.sleep(config.ROOMS_UPDATE_INTERVAL_SECONDS)
await self._update()
def pause(self):
self.player_state = PlayerState.PAUSED
def play(self):
self.player_state = PlayerState.PLAYING
def stop(self):
self.player_state = PlayerState.STOPPED
def seek(self, seconds: float):
self.player_position = seconds
self.last_update = datetime.now()
+7
View File
@@ -0,0 +1,7 @@
from enum import Enum
class PlayerState(Enum):
PLAYING = "playing"
PAUSED = "paused"
STOPPED = "stopped"
+8
View File
@@ -0,0 +1,8 @@
import random
import string
CHARACTERS = string.ascii_letters
def generate_uid(length: int = 6) -> str:
return "".join(random.choice(CHARACTERS) for _ in range(length))
+7
View File
@@ -0,0 +1,7 @@
class Singleton(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
return cls._instances[cls]
+26
View File
@@ -0,0 +1,26 @@
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=False)
class User:
username: str
uid: str
_last_activity_str: str = None
def __init__(self, username: str, uid: str, last_activity: datetime):
self.username = username
self.uid = uid
self.last_activity = last_activity
@property
def last_activity(self) -> datetime:
return datetime.strptime(self._last_activity_str, "%Y-%m-%dT%H:%M:%S.%f")
@last_activity.setter
def last_activity(self, value: datetime | str):
if type(value) is str:
self._last_activity_str = value
else:
self._last_activity_str = value.strftime("%Y-%m-%dT%H:%M:%S.%f")
+148
View File
@@ -0,0 +1,148 @@
import asyncio
import logging
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
import aiofiles
import orjson
import config
from singleton import Singleton
from users.classes import User
from users.utils import is_inactive_too_long, decode_token, generate_uid
class UsersDB(metaclass=Singleton):
def __init__(self, db_path: Path | str):
self.path = Path(db_path)
self.users = []
self.by_uid = {}
def _assign_users(self):
self.by_uid = {}
for user in self.users:
self.by_uid[user.uid] = user
async def auto_remove_inactive(self):
while True:
await asyncio.sleep(config.REMOVE_INACTIVE_USERS_INTERVAL_SECONDS)
await self.remove_inactive()
async def remove_inactive(self):
logging.info("Removing inactive users")
new_users = []
for user in self.users:
if not is_inactive_too_long(user):
new_users.append(user)
continue
logging.info(f"Removing inactive user {user.uid}")
self.users = new_users
await self.save_to_disk()
self._assign_users()
logging.info("Finished Removing inactive users")
async def get_user_by_token(self, token: str) -> User | None:
user = decode_token(token)
if user is None:
return None
if user.uid not in self.by_uid.keys():
return None
if is_inactive_too_long(user):
await self.delete_user(user.uid)
return None
return user
async def create_user(self, username: str) -> User:
user = User(
username=username,
uid=generate_uid(username),
last_activity=datetime.now(),
)
self.users.append(user)
await self.save_to_disk()
self._assign_users()
logging.info("Created new user - %s", user.uid)
return user
async def delete_user(self, uid: str):
user = self.by_uid.get(uid)
if not user:
return
self.users.remove(user)
await self.save_to_disk()
self._assign_users()
logging.info("Deleted user - %s", user.uid)
async def update_user(self, uid: str):
user = self.by_uid.get(uid)
if not user:
return
self.users.remove(user)
del self.by_uid[uid]
user.last_activity = datetime.now()
self.users.append(user)
self.by_uid[uid] = user
await self.save_to_disk()
self._assign_users()
logging.info("Updated user - %s", user.uid)
async def save_to_disk(self):
logging.info("Saving Users DB to disk")
users = [asdict(user) for user in self.users]
for i in range(len(users)):
users[i]["last_activity"] = self.users[i]._last_activity_str
del users[i]["_last_activity_str"]
to_save = await asyncio.to_thread(orjson.dumps, users)
async with aiofiles.open(self.path, "wb") as file:
await file.write(to_save)
logging.info("Finished Saving Users DB to disk")
async def load_from_disk(self):
logging.info("Loading Users DB from disk")
if not self.path.exists():
return
async with aiofiles.open(self.path, "rb") as file:
file_content = await file.read()
if len(file_content) == 0:
return
loaded_db = await asyncio.to_thread(orjson.loads, file_content)
new_users = []
for user in loaded_db:
new_user = User(**user)
new_users.append(new_user)
self.users = new_users
self._assign_users()
logging.info("Finished Loading Users DB from disk")
+46
View File
@@ -0,0 +1,46 @@
import logging
import uuid
from dataclasses import asdict
from datetime import datetime, timedelta
import bcrypt
import jwt
import config
from users.classes import User
def generate_uid(username: str) -> str:
uid_bytes = (username + uuid.uuid4().hex).encode("utf-8")
uid_hash = bcrypt.hashpw(uid_bytes, bcrypt.gensalt())
return uid_hash.decode("utf-8")
def generate_token(user: User) -> str:
user.last_activity = datetime.now()
user_dict = asdict(user)
encoded_jwt = jwt.encode(user_dict, config.SECRET, algorithm="HS256")
return encoded_jwt
def decode_token(token: str) -> User | None:
decoded_dict = jwt.decode(token, config.SECRET, algorithms="HS256")
decoded_dict["last_activity"] = decoded_dict["_last_activity_str"]
del decoded_dict["_last_activity_str"]
try:
return User(**decoded_dict)
except Exception as e:
logging.error(e)
return None
def is_inactive_too_long(user: User) -> bool:
dt1 = user.last_activity
dt2 = datetime.now()
diff = dt2 - dt1
max_diff = timedelta(hours=config.MAX_USER_INACTIVE_HOURS)
if diff > max_diff:
return True
return False
Generated
+1131
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
from web.web import *
+3
View File
@@ -0,0 +1,3 @@
from web.custom_widgets.content_card import ContentCard
from web.custom_widgets.content_dialog import ContentDialog
from web.custom_widgets.header import draw_header
+48
View File
@@ -0,0 +1,48 @@
from nicegui import ui
import globals
from web.custom_widgets import ContentDialog
class ContentCard:
def __init__(self, tmdb_id: int):
self.content = globals.MOVIES_DATABASE.by_tmdb_id[tmdb_id]
self.dialog = ContentDialog(tmdb_id)
self.card = ui.card()
self.card.classes("no-shadow")
self.card.style("margin: 0; padding: 6px; border-radius: 20px")
with self.card:
self.poster_image = ui.image(source=self.content.poster_url)
self.poster_image.style("width: 200px; height: 100%; border-radius: 15px")
with self.poster_image:
self.poster_column = ui.column()
self.poster_column.classes("w-full h-full justify-between")
self.poster_column.style(
"margin: 0; padding:0; opacity: 0; transition-duration: 0.5s")
with self.poster_column:
with ui.column(wrap=False).classes("w-full").style("gap: 0px; margin: 0; padding: 3px 3px 3px 10px;"):
ui.html(f"<b>{self.content.title}</b>").style("font-size: 16px")
ui.html(f"<i>{self.content.og_title}</i>").style("font-size: 10px")
with ui.column(wrap=True).classes("w-full").style("gap: 0px; margin: 0; padding: 3px 3px 10px 10px"):
ui.html(f"<b><i>{"<br>".join((self.content.genres))}</i></b>").style("font-size: 8px")
self.card.on("mouseover", self.on_card_hover)
self.card.on("mouseleave", self.on_card_unhover)
self.card.on("click", self.on_card_click)
def on_card_hover(self):
self.poster_column.style("opacity: 0.9")
self.card.style("transform: scale(1.02)")
def on_card_unhover(self):
self.poster_column.style("opacity: 0")
self.card.style("transform: scale(1.0)")
def on_card_click(self):
self.dialog.open()
+66
View File
@@ -0,0 +1,66 @@
import itertools
from nicegui import ui
import globals
from web.misc import convert_runtime
class ContentDialog(ui.dialog):
def __init__(self, tmdb_id: int, *args, **kwargs):
super().__init__(*args, **kwargs)
self.content = globals.MOVIES_DATABASE.by_tmdb_id[tmdb_id]
with self:
self.card = ui.card()
self.card.classes("h-max no-shadow")
self.card.style("min-width: 40%; min-height: 60%; border-radius: 15px")
with self.card:
self.title_column = ui.column(wrap=False)
self.title_column.classes("w-full")
self.title_column.style("gap: 0; margin: 0; padding: 0;")
with self.title_column:
ui.html(f"<b>{self.content.title}</b>").style("font-size: 22px")
ui.html(f"<i>{self.content.og_title}</i>").style("font-size: 14px")
ui.html(f"<b><i>{", ".join(self.content.genres)}</i></b>").style("font-size: 12px")
with self.card:
self.main_row = ui.row(wrap=False)
self.main_row.classes("w-full h-full")
with self.main_row:
ui.image(source=self.content.poster_url).style("width: 50%; border-radius: 15px")
self.additional_info_column = ui.column(wrap=False)
self.additional_info_column.style("width: 50%")
with self.additional_info_column:
release_date = self.content.release_date.split("-")[::-1]
release_date = ".".join(release_date)
ui.html(f"<b>Release Date: </b>{release_date}").style("font-size: 12px")
ui.html(f"<b>Average Score: </b>{round(self.content.vote_average, 2)}").style("font-size: 12px")
if self.content.type == "movie":
budget = "$" + f"{self.content.budget:_}".replace("_", ".")
ui.html(f"<b>Budget: </b>{budget}").style("font-size: 12px")
ui.html(f"<b>Runtime: </b>{convert_runtime(self.content.runtime)}").style("font-size: 12px")
elif self.content.type == "tv":
ui.html(f"<b>Number of Seasons: </b>{self.content.number_of_seasons}").style("font-size: 12px")
ui.html(f"<b>Number of Episodes: </b>{self.content.number_of_episodes}").style("font-size: 12px")
total_runtime = sum([ep.runtime for ep in
itertools.chain.from_iterable([s.episodes for s in self.content.seasons])])
ui.html(f"<b>Total Runtime: </b>{convert_runtime(total_runtime)}").style("font-size: 12px")
if self.content.in_production:
ui.html(f"<b><i>Currently in production</i></b>").style("font-size: 12px")
else:
ui.html(f"<b><i>Finished</i></b>").style("font-size: 12px")
with self.card, ui.row(wrap=False).classes("w-full").style("display: flex; justify-content: center;"):
ui.button("Create Room").on_click(self.create_room)
async def create_room(self):
room = await globals.ROOMS_DATABASE.create_room(self.content.tmdb_id)
ui.navigate.to(f"/room/{room.uid}")
+11
View File
@@ -0,0 +1,11 @@
from nicegui import ui
from web.misc import logout
async def draw_header():
with ui.header(wrap=False):
with ui.row(wrap=False).classes("w-full justify-between") as header_row:
ui.button(icon="home", on_click=lambda: ui.navigate.to("/"))
ui.button(text="Rooms", icon="movie", on_click=lambda: ui.navigate.to("/rooms"))
ui.button(icon="logout", on_click=lambda: logout(redirect=True))
+50
View File
@@ -0,0 +1,50 @@
from nicegui import ui, app
import globals
from users.classes import User
def convert_runtime(total_minutes: int) -> str:
hours = total_minutes // 60
minutes = total_minutes % 60
if hours > 0 and minutes > 0:
return f"{hours} h {minutes} m"
elif hours > 0:
return f"{hours} h"
elif minutes > 0:
return f"{minutes} m"
else:
return "0 m"
def logout(redirect: bool = False):
if app.storage.user.get("token"):
app.storage.user.pop("token")
if redirect:
ui.navigate.to("/")
async def check_user() -> User | None:
if token := app.storage.user.get("token"):
user = await globals.USERS_DATABASE.get_user_by_token(token)
if user:
return user
else:
logout()
return None
else:
return None
async def update_user():
if token := app.storage.user.get("token"):
user = await globals.USERS_DATABASE.get_user_by_token(token)
if user:
await globals.USERS_DATABASE.update_user(user.uid)
else:
app.storage.user.pop("token")
ui.navigate.to("/")
else:
ui.navigate.to("/")
+4
View File
@@ -0,0 +1,4 @@
import web.pages.all_rooms as rooms_page
import web.pages.contents as movies_page
import web.pages.index as index_page
import web.pages.room as room_page
+30
View File
@@ -0,0 +1,30 @@
from nicegui import ui
import globals
from web.custom_widgets import draw_header
from web.misc import check_user
async def page():
ui.page_title("Watch With Friends - Rooms")
if not await check_user():
ui.navigate.to("/")
await draw_header()
main_row = ui.row().classes("w-full")
for room in globals.ROOMS_DATABASE.rooms:
with main_row, ui.link(target=f"/room/{room.uid}"), ui.card().style("border-radius: 15px"):
content = globals.MOVIES_DATABASE.by_tmdb_id[room.tmdb_id]
with ui.row(wrap=False):
ui.image(content.poster_url).style("width: 20%")
with ui.column(wrap=False).classes("").style("gap: 0px"):
ui.html(f"{room.uid}")
ui.html(f"<b>{content.title}</b>")
users = [globals.USERS_DATABASE.by_uid[uid] for uid in room.connected_users]
ui.html(f"<i>{", ".join(u.username for u in users)}</i>")
+18
View File
@@ -0,0 +1,18 @@
from nicegui import ui
import globals
from web.custom_widgets import draw_header, ContentCard
from web.misc import check_user
async def page():
ui.page_title("Watch With Friends - Contents")
if not await check_user():
ui.navigate.to("/")
await draw_header()
with ui.row(wrap=True).classes("w-full").style("margin: auto; gap: 16px"):
for content in globals.MOVIES_DATABASE.contents:
ContentCard(content.tmdb_id)
+43
View File
@@ -0,0 +1,43 @@
from nicegui import ui, app
import config
import globals
from users.utils import generate_token
from web.misc import check_user
async def page():
ui.page_title("Watch With Friends")
if not await check_user():
await handle_login()
ui.navigate.to("/contents")
async def handle_login():
async def try_create_user():
login = login_input.value.strip()
password = password_input.value.strip()
if password != config.PASSWORD:
ui.notify("Incorrect password!", type="negative", position="top")
return
new_user = await globals.USERS_DATABASE.create_user(login)
token = generate_token(new_user)
if token:
app.storage.user["token"] = token
dialog.close()
else:
ui.notify("Something went wrong!", type="negative", position="top")
with ui.dialog().props("persistent") as dialog, ui.card():
ui.label("Login")
with ui.column():
login_input = ui.input("Username").classes("w-full")
password_input = ui.input("Password", password=True, password_toggle_button=True).classes("w-full")
ui.button("Login", on_click=try_create_user).classes("w-full")
await dialog
+227
View File
@@ -0,0 +1,227 @@
import asyncio
import logging
from functools import partial
from nicegui import ui
import config
import globals
from rooms.state import PlayerState
from web.custom_widgets.header import draw_header
from web.misc import check_user
def _check_room(room_uid: str):
return not globals.ROOMS_DATABASE.by_uid.get(room_uid) is None
async def _join_room(room_uid: str, user_uid: str):
for _ in range(10):
if globals.ROOMS_DATABASE.by_uid.get(room_uid) is None:
await asyncio.sleep(0.1)
continue
break
if globals.ROOMS_DATABASE.by_uid.get(room_uid) is None:
ui.notify(f"Failed to join room {room_uid}, redirecting to /rooms...", type="warning")
await asyncio.sleep(1)
ui.navigate.to("/rooms")
return
globals.ROOMS_DATABASE.by_uid[room_uid].connected_users.append(user_uid)
logging.info(f"{user_uid} joined room {room_uid}")
def _leave_room(room_uid: str, user_uid: str):
if user_uid in globals.ROOMS_DATABASE.by_uid[room_uid].connected_users:
globals.ROOMS_DATABASE.by_uid[room_uid].connected_users.remove(user_uid)
logging.info(f"{user_uid} left room {room_uid}")
def _change_episode(room_uid: str, tmdb_id: int, season_number: int, episode_number: int, seasons_column: ui.column,
video_player: ui.video, player_data: dict):
try:
new_episode = globals.MOVIES_DATABASE.by_tmdb_id[tmdb_id].seasons[season_number - 1].episodes[
episode_number - 1]
except KeyError:
ui.notify("Episode not found", type="negative")
return
room = globals.ROOMS_DATABASE.by_uid[room_uid]
room.current_season, room.current_episode = season_number, episode_number
room.player_position = 0
room.player_state = PlayerState.PAUSED
player_data["season"], player_data["episode"] = season_number, episode_number
player_data["position"] = 0
video_player.pause()
video_player.seek(0)
video_player.pause()
video_player.set_source(new_episode.file_url)
seasons_column.clear()
_draw_seasons(room_uid, tmdb_id, seasons_column, video_player, player_data)
def _draw_users_list(room_uid: str, users_card: ui.card):
users_card.clear()
room = globals.ROOMS_DATABASE.by_uid[room_uid]
for user_uid in room.connected_users:
user = globals.USERS_DATABASE.by_uid[user_uid]
with users_card, ui.card().classes("w-full"):
ui.label(user.username)
def _draw_seasons(room_uid: str, tmdb_id: int, seasons_column: ui.column, video_player: ui.video,
player_data: dict):
room = globals.ROOMS_DATABASE.by_uid[room_uid]
tv_show = globals.MOVIES_DATABASE.by_tmdb_id[tmdb_id]
for season_number in range(1, tv_show.number_of_seasons + 1):
season = tv_show.seasons[season_number - 1]
with seasons_column, ui.card().classes("w-full"):
ui.html(f"<b>{season.title}</b>")
with ui.row().classes("w-full"):
episodes = tv_show.seasons[season_number - 1].episodes
for episode_number in range(1, len(episodes) + 1):
episode_button = ui.button(str(episode_number),
on_click=partial(_change_episode, room_uid, tmdb_id, season_number,
episode_number, seasons_column, video_player,
player_data))
if room.current_season == season_number and room.current_episode == episode_number:
episode_button.disable()
async def _on_seeked(room_uid: str, video_player: ui.video, player_data: dict):
try:
position = await ui.run_javascript(f'getHtmlElement({video_player.id}).currentTime')
except TimeoutError:
return
globals.ROOMS_DATABASE.by_uid[room_uid].seek(position)
player_data["position"] = position
async def _on_play(room_uid: str, video_player: ui.video, player_data: dict):
room = globals.ROOMS_DATABASE.by_uid[room_uid]
room.play()
player_data["state"] = PlayerState.PLAYING
await _on_seeked(room_uid, video_player, player_data)
async def _on_pause(room_uid: str, video_player: ui.video, player_data: dict):
room = globals.ROOMS_DATABASE.by_uid[room_uid]
room.pause()
player_data["state"] = PlayerState.PAUSED
await _on_seeked(room_uid, video_player, player_data)
async def _on_stop(room_uid: str, video_player: ui.video, player_data: dict):
room = globals.ROOMS_DATABASE.by_uid[room_uid]
room.stop()
player_data["state"] = PlayerState.STOPPED
await _on_seeked(room_uid, video_player, player_data)
video_player.pause()
async def _sync(room_uid: str, tmdb_id: int, seasons_column: ui.column, video_player: ui.video,
player_data: dict):
try:
player_data["position"] = await ui.run_javascript(f'getHtmlElement({video_player.id}).currentTime')
except TimeoutError:
return
room = globals.ROOMS_DATABASE.by_uid[room_uid]
if player_data["state"] != room.player_state:
match room.player_state:
case PlayerState.PLAYING:
video_player.play()
player_data["state"] = PlayerState.PLAYING
case PlayerState.PAUSED:
video_player.pause()
player_data["state"] = PlayerState.PAUSED
case PlayerState.STOPPED:
video_player.pause()
player_data["state"] = PlayerState.STOPPED
if abs(player_data["position"] - room.player_position) > config.MAX_DELAY_SECONDS:
video_player.seek(room.player_position)
player_data["position"] = room.player_position
if player_data["season"] != room.current_season or player_data["episode"] != room.current_episode:
player_data["season"], player_data["episode"] = room.current_season, room.current_episode
_change_episode(room_uid, tmdb_id, room.current_season, room.current_episode, seasons_column, video_player,
player_data)
async def page(room_uid: str):
if not _check_room(room_uid):
ui.navigate.to("/rooms")
return
user = await check_user()
if not user:
ui.navigate.to("/")
return
await draw_header()
await _join_room(room_uid, user.uid)
player_data = {
"state": PlayerState.PAUSED,
"position": 0,
"season": None,
"episode": None
}
with ui.row(wrap=False).classes("w-full"):
player_card = ui.card()
player_card.classes("no-shadow")
player_card.style("width: 80%; height: 85vh; border-radius: 15px")
player_card.style("display: flex; justify-content: center; align-items: center;")
users_card = ui.card()
users_card.classes("no-shadow")
users_card.style("width: 20%; height: 85vh; border-radius: 15px")
ui.timer(1, partial(_draw_users_list, room_uid, users_card))
with player_card:
video_player = ui.video(src="")
video_player.classes("w-full h-full")
video_player.style("border-radius: 15px")
video_player.on("play", partial(_on_play, room_uid, video_player, player_data))
video_player.on("pause", partial(_on_pause, room_uid, video_player, player_data))
video_player.on("seeked", partial(_on_seeked, room_uid, video_player, player_data))
video_player.on("ended", partial(_on_stop, room_uid, video_player, player_data))
seasons_column = ui.column().classes("w-full")
seasons_column.visible = False
tmdb_id = globals.ROOMS_DATABASE.by_uid[room_uid].tmdb_id
content = globals.MOVIES_DATABASE.by_tmdb_id[tmdb_id]
if content.type == "movie":
source = content.file_url
elif content.type == "tv":
source = content.seasons[0].episodes[0].file_url
room = globals.ROOMS_DATABASE.by_uid[room_uid]
room.current_season, room.current_episode = 1, 1
player_data["season"], player_data["episode"] = 1, 1
seasons_column.visible = True
_draw_seasons(room_uid, tmdb_id, seasons_column, video_player, player_data)
else:
source = ""
video_player.set_source(source)
ui.timer(1, partial(_sync, room_uid, tmdb_id, seasons_column, video_player, player_data))
await ui.context.client.disconnected()
_leave_room(room_uid, user.uid)
+40
View File
@@ -0,0 +1,40 @@
from nicegui import ui
import config
from web.misc import update_user
from web.pages import *
@ui.page("/")
async def index():
ui.add_head_html("<meta name=\"referrer\" content=\"no-referrer\" />")
await ui.context.client.connected(timeout=config.CONNECTION_TIMEOUT_SECONDS)
await index_page.page()
@ui.page("/contents")
async def movies():
ui.add_head_html("<meta name=\"referrer\" content=\"no-referrer\" />")
await ui.context.client.connected(timeout=config.CONNECTION_TIMEOUT_SECONDS)
ui.timer(60, update_user)
await movies_page.page()
@ui.page("/rooms")
async def rooms():
ui.add_head_html("<meta name=\"referrer\" content=\"no-referrer\" />")
await ui.context.client.connected(timeout=config.CONNECTION_TIMEOUT_SECONDS)
ui.timer(60, update_user)
await rooms_page.page()
@ui.page("/room/{room_uid}")
async def room(room_uid: str):
ui.add_head_html("<meta name=\"referrer\" content=\"no-referrer\" />")
ui.add_head_html('''
<link href="https://vjs.zencdn.net/7.21.1/video-js.css" rel="stylesheet" />
<script src="https://vjs.zencdn.net/7.21.1/video.min.js"></script>
''')
await ui.context.client.connected(timeout=config.CONNECTION_TIMEOUT_SECONDS)
ui.timer(60, update_user)
await room_page.page(room_uid)