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