initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
venv
|
||||
.venv
|
||||
__pycache__
|
||||
.idea
|
||||
.nicegui
|
||||
data
|
||||
config.py
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -0,0 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class PlayerState(Enum):
|
||||
PLAYING = "playing"
|
||||
PAUSED = "paused"
|
||||
STOPPED = "stopped"
|
||||
@@ -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))
|
||||
@@ -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]
|
||||
@@ -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
@@ -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")
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
from web.web import *
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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("/")
|
||||
@@ -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
|
||||
@@ -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>")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
Reference in New Issue
Block a user