Compare commits

..

10 Commits

Author SHA1 Message Date
trueold89 f88f007910
Reformat exceptions
- init static.exceptions.py
- reformat exceptions
- add static.functions.validate() messages
2024-08-07 19:49:55 +03:00
trueold89 eb1dc48a11
Add Controller object
- init controller.py
- Add Controller class
- Add AUTH_PASSWD env var
- Add new method 'is_user_exist' to CacheDB
2024-08-07 19:01:48 +03:00
trueold89 c2ab00680f
Add InitBuilder 2024-08-06 14:59:50 +03:00
trueold89 697dac6922
Rewrite CacheDB && Implements UserStates 2024-08-06 13:59:44 +03:00
trueold89 dd27264fd5
Add Redis implementation of CacheDB
- Add RedisCache class
- Add REDIS_HOST env var
2024-08-05 21:21:13 +03:00
trueold89 1e3ce3a55e
Add PN_CACHE env value
- Add ENV abstract class to static
- Init static.env
- Add PN_CACHE env
2024-08-05 17:43:58 +03:00
trueold89 015e01b6be
Add PythonCache implementation of CacheDB
- add User class to db.types
- init db.cache
- Add PythonCache class
2024-08-05 16:43:00 +03:00
trueold89 ba696f4c56
Add CacheDB abstract class
- init tubot.db
- init tubot.db.types
- init tubot.db.abc
- add CacheDBTypes enum
- add UserStates enum
- add CacheDB abstract class
2024-08-05 15:42:35 +03:00
trueold89 ebca2172f4
Add JellyfinAPI implementation of DirGetter 2024-08-05 15:22:29 +03:00
trueold89 b00c60f448
Add OSGetter implementation of DirGetter
- init dirgetter.getter
- add OSGetter class
2024-08-05 14:56:18 +03:00
16 changed files with 800 additions and 12 deletions

View File

@ -7,4 +7,5 @@ frozenlist>=1.4.1
idna>=3.7
multidict>=6.0.5
python-magic>=0.4.27
redis>=5.0.8
yarl>=1.9.4

View File

@ -7,6 +7,11 @@ setup(
author="ORUDO",
author_email="root@orudo.ru",
description="A simple Telegram bot that will allow you to upload torrent files / magnet links to a remote Torrent server (qBitTorrent, Transmission, etc.)",
install_requires=["aiohttp>=3.10.0", "aiofiles>=24.1.0", "aiofiles>=24.1.0"],
packages=["tubot", "tubot.static", "tubot.torrent"],
install_requires=[
"aiohttp>=3.10.0",
"aiofiles>=24.1.0",
"aiofiles>=24.1.0",
"redis>=5.0.8",
],
packages=["tubot", "tubot.static", "tubot.torrent", "tubot.dirgetter", "db"],
)

75
tubot/db/abc.py Normal file
View File

@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
####################################
# DataBase module abstract classes #
####################################
# Imports
from abc import ABC, abstractmethod
from tubot.static.abc import IValidatable
from tubot.db.types import CacheDBTypes, User
class CacheDB(IValidatable, ABC):
"""
Abstract class for CacheDB
"""
_ctype: CacheDBTypes
def __init__(self) -> None:
if self._ctype is None:
raise NotImplementedError("CacheDB type not implemented")
# Users
@abstractmethod
async def write_user(self, tg_id: int, user: User) -> None:
"""
Writes user to cache db
:param tg_id: User telegram id
:param user: User object
"""
raise NotImplementedError
@abstractmethod
async def read_user(self, tg_id: int) -> User:
"""
Writes user to cache db
:param tg_id: User telegram id
:return: User object
"""
raise NotImplementedError
@abstractmethod
async def chech_user_existing(self, tg_id: int):
"""
Checks if user exist in db
:param tg_id: User telegram id
"""
raise NotImplementedError
# Dirs
@abstractmethod
async def cache_dirs(self, dirs: dict, expire: int) -> None:
"""
Cache dirs from DirectoryGetter
:param dirs: Dirs dict
:param expire: Expire time (in seconds)
"""
raise NotImplementedError
@property
@abstractmethod
async def get_dirs(self) -> dict:
"""
Returns precached dirs
:return: Dirs dict
"""
raise NotImplementedError

152
tubot/db/cache.py Normal file
View File

@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
###########################
# CacheDB implementations #
###########################
# Imports
from tubot.db.abc import CacheDB
from tubot.db.types import CacheDBTypes, UserStates, User
from tubot.static.env import PN_CACHE
from pickle import loads, dumps
from aiofiles.ospath import isdir, isfile
from aiofiles.os import mkdir
from aiofiles import open
from asyncio import sleep
from redis import asyncio as aioredis
from json import loads as json_loads
from json import dumps as json_dumps
class PythonCache(CacheDB):
"""
Native python implementation of Cache DataBase
"""
CACHE_DIR = PN_CACHE()()
_ctype = CacheDBTypes.PythonPKL
users: dict
dirs: dict
def __init__(self) -> None:
super().__init__()
async def _init(self) -> bool:
self.users = {}
self.dirs = {}
if await isfile(f"{self.CACHE_DIR}/user_cache.pkl"):
try:
await self._load_pkl()
except Exception:
return False
return True
async def _load_pkl(self) -> None:
if not await isdir(self.CACHE_DIR):
await mkdir(self.CACHE_DIR)
async with open(f"{self.CACHE_DIR}/user_cache.pkl", "rb") as file:
buffer = await file.read()
pkl = loads(buffer)
self.users = pkl
async def _save_pkl(self) -> None:
if not await isdir(self.CACHE_DIR):
await mkdir(self.CACHE_DIR)
async with open(f"{self.CACHE_DIR}/user_cache.pkl", "wb") as file:
await file.write(dumps(self.users))
async def __validate__(self) -> bool:
return await self._init()
# Users
async def write_user(self, tg_id: int, user: User) -> None:
self.users[tg_id] = user.to_dict
await self._save_pkl()
async def read_user(self, tg_id: int) -> User:
user_data = self.users[tg_id]
return User.from_dict(user_data)
async def chech_user_existing(self, tg_id: int) -> bool:
try:
await self.read_user(tg_id)
return True
except KeyError:
return False
# Dirs
async def cache_dirs(self, dirs: dict, expire: int) -> None:
self.dirs = dirs
await sleep(expire)
self.dirs = {}
@property
async def get_dirs(self) -> dict:
return self.dirs
class RedisCache(CacheDB):
"""
Redis implementation of Cache DataBase
"""
_ctype = CacheDBTypes.Redis
host: str
def __init__(self, redis_host: str) -> None:
super().__init__()
self.host = redis_host
async def __validate__(self) -> bool:
async with aioredis.from_url(
f"redis://{self.host}", encoding="utf-8", decode_responses=True
) as redis:
return await redis.ping()
# Users
async def write_user(self, tg_id: int, user: User) -> None:
async with aioredis.from_url(
f"redis://{self.host}", encoding="utf-8", decode_responses=True
) as redis:
json = json_dumps(user.to_dict)
await redis.set(str(tg_id), json)
async def read_user(self, tg_id: int) -> User:
async with aioredis.from_url(
f"redis://{self.host}", encoding="utf-8", decode_responses=True
) as redis:
json = await redis.get(str(tg_id))
if json is None:
raise KeyError
user_data = json_loads(json)
user_data["state"] = UserStates(user_data["state"])
return User.from_dict(user_data)
async def chech_user_existing(self, tg_id: int) -> bool:
try:
await self.read_user(tg_id)
return True
except KeyError:
return False
# Dirs
async def cache_dirs(self, dirs: dict, expire: int) -> None:
async with aioredis.from_url(
f"redis://{self.host}", encoding="utf-8", decode_responses=True
) as redis:
json = json_dumps(dirs)
await redis.set("dirs", json, ex=expire)
@property
async def get_dirs(self) -> dict:
async with aioredis.from_url(
f"redis://{self.host}", encoding="utf-8", decode_responses=True
) as redis:
resp = await redis.get("dirs")
if resp is None:
return {}
return json_loads(resp)

67
tubot/db/types.py Normal file
View File

@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
#############################
# Types for DataBase module #
#############################
# Imports
from enum import Enum
class CacheDBTypes(Enum):
"""
Types of CacheDB
"""
PythonPKL = "python"
Redis = "redis"
class UserStates(Enum):
"""
Types of User status
"""
IDLE = "IDLE"
DIRS = "DIRS"
WAIT_FOR_TORRENT = "WAIT_FOR_TORRENT"
class User(object):
"""
User class
"""
tg_id: int
name: str
state: UserStates = UserStates.IDLE
auth: bool = False
def __init__(
self,
tg_id: int,
name: str,
state: UserStates = UserStates.IDLE,
auth: bool = False,
) -> None:
self.tg_id = tg_id
self.name = name
self.state = state
self.auth = auth
@property
def to_dict(self):
return {
"tg_id": self.tg_id,
"name": self.name,
"state": self.state.value,
"auth": self.auth,
}
@classmethod
def from_dict(cls, usr: dict) -> "User":
tg = usr["tg_id"]
name = usr["name"]
state = UserStates(usr["state"])
auth = usr["auth"]
return cls(tg, name, state, auth)

View File

@ -11,6 +11,9 @@ from tubot.dirgetter.types import GetterTypes
class DirGetter(IValidatable, ABC):
"""
DirectoryGetter Abstract class
"""
_gtype: GetterTypes

96
tubot/dirgetter/getter.py Normal file
View File

@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
####################################
# Directory-Getter implementations #
####################################
# Imports
from tubot.dirgetter.types import GetterTypes
from tubot.dirgetter.abc import DirGetter
from aiofiles.os import listdir
from aiofiles.ospath import isdir
from aiohttp import ClientResponse, ClientSession
class OSGetter(DirGetter):
"""
Python.os module implementation of DirectoryGetter
"""
_gtype = GetterTypes.OS
base_dir: str
def __init__(self, base_dir: str) -> None:
"""
:param base_dir: Path to parent directory
"""
super().__init__()
self.base_dir = base_dir
@property
async def folders(self) -> dict:
dirs = {}
ls = await listdir(self.base_dir)
if len(ls) == 0:
raise KeyError("No dirs found")
for item in ls:
if await isdir(f"{self.base_dir}/{item}"):
dirs[item] = f"{self.base_dir}/{item}"
return dirs
async def __validate__(self) -> bool:
return await isdir(self.base_dir)
class Jellyfin(DirGetter):
"""
Jellyfin API implementation of DirectoryGetter
"""
_gtype = GetterTypes.Jellyfin
host: str
token: str
def __init__(self, host: str, api_token: str) -> None:
"""
:param host: Adress of Jellyfin server
:param api_token: Jellyfin API Token for auth
"""
super().__init__()
self.host = host
self.token = api_token
async def _get(self, api: str) -> ClientResponse:
async with ClientSession() as session:
resp = await session.get(f"{self.host}/{api}?api_key={self.token}")
status = resp.status
match status:
case 200:
return resp
case 401:
raise ConnectionError("401: Auth error")
case 403:
raise ConnectionError("403: Forbidden")
case 404:
raise ConnectionError("403: Not found")
raise ConnectionError()
@property
async def idx(self) -> str | None:
resp = await self._get("System/Info")
json = await resp.json()
return json["Id"]
@property
async def folders(self) -> dict:
resp = await self._get("Library/VirtualFolders")
json = await resp.json()
dirs = {}
for folder in json:
dirs[folder["Name"]] = folder["Locations"][0]
return dirs
async def __validate__(self) -> bool:
if await self.idx is not None:
return True
return False

View File

@ -13,5 +13,5 @@ class GetterTypes(Enum):
Types of getters
"""
OS = "Python os module"
Jellyfin = "Jelyfin API"
OS = "os"
Jellyfin = "jellyfin"

View File

@ -6,6 +6,7 @@
# Imports
from abc import ABC, abstractmethod
from os import environ
class IValidatable(ABC):
@ -22,3 +23,27 @@ class IValidatable(ABC):
:return: Object validity boolean
"""
raise NotImplementedError
class ENV(object):
_name: str | None = None
DEFAULT: str
def __init__(self) -> None:
if self._name is None or self.DEFAULT is None:
raise NotImplementedError
@property
def from_os(self) -> str | None:
if self._name is not None:
return environ.get(self._name)
@property
def value(self) -> str:
val = self.from_os
if val is not None:
return val
return self.DEFAULT
def __call__(self) -> str:
return self.value

120
tubot/static/controller.py Normal file
View File

@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
############################
# Controller static module #
############################
# Imports
from tubot.torrent.abc import TorrentAPI, TorrentObj
from tubot.dirgetter.abc import DirGetter
from tubot.db.abc import CacheDB
from tubot.db.types import User, UserStates
from tubot.static.functions import validate
from tubot.static.env import AUTH_PASSWD
from tubot.static.exceptions import AlreadyExists, AuthError
class Controller(object):
"""
Controller object
"""
torrent: TorrentAPI
getter: DirGetter
cache: CacheDB
def __init__(self, torrent_api: TorrentAPI, dg: DirGetter, cache: CacheDB) -> None:
"""
:param torrent_api: TorrentAPI module
:param dg: DirectoryGetter module
:param cache: CacheDB module
"""
self.torrent = torrent_api
self.getter = dg
self.cache = cache
# DG
async def get_dirs(self) -> dict:
"""
Returns dict of dirs from cache / dg
"""
dirs = await self.cache.get_dirs
if len(dirs) > 0:
return dirs
dirs = await self.getter.folders
if len(dirs) > 0:
return dirs
raise KeyError("No dirs found")
# Torrent
async def upload_torrent(self, torrent: TorrentObj) -> None:
"""
Add torrent to query
:param torrent: Torrent object
"""
await validate(torrent, "Wrong torrent object")
await self.torrent.upload(torrent)
async def get_torrent_list(self) -> str:
"""
Returns message with current torrents list
"""
return await self.torrent.torrent_list
# Users
async def _create_user(self, tg_id: int, name: str) -> User:
"""
Creates user in database
:param tg_id: Telegram id
:param name: Telegram profile name
"""
user_obj = User(tg_id=tg_id, name=name)
await self.cache.write_user(tg_id, user_obj)
return user_obj
async def _get_user_from_db(self, tg_id: int) -> User:
"""
Gets user from database
:param tg_id: Telegram id
"""
return await self.cache.read_user(tg_id)
async def get_user(self, tg_id: int, name: str) -> User:
"""
Returns user object
:param tg_id: Telegram id
:param name: Telegram profile name
"""
if await self.cache.chech_user_existing(tg_id):
return await self._get_user_from_db(tg_id)
return await self._create_user(tg_id, name)
async def auth_user(self, user: User, pwd: str) -> None:
"""
Auth user
:param user: Current user object
"""
if user.auth:
raise AlreadyExists("You already auth")
if pwd == AUTH_PASSWD()():
user.auth = True
await self.cache.write_user(user.tg_id, user)
raise AuthError("Wrong password")
async def set_user_state(self, user: User, state: UserStates) -> None:
"""
Change user status
:param user: Current user object
:param state: New user status
"""
user.state = state
await self.cache.write_user(user.tg_id, user)

116
tubot/static/env.py Normal file
View File

@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
############
# ENV Vars #
############
# Imports
from tubot.static.abc import ENV
class PN_CACHE(ENV):
"""
Python Native Cache dir
"""
_name = "PN_CACHE"
DEFAULT = "/etc/tubot"
class REDIS_HOST(ENV):
"""
Redis host adress
"""
_name = "REDIS_HOST"
DEFAULT = "localhost:6379"
class CACHE_TYPE(ENV):
"""
CacheDB Type
"""
_name = "CACHE_TYPE"
DEFAULT = "python"
class DIR_GETTER(ENV):
"""
DirGetter Type
"""
_name = "DIR_GETTER"
DEFAULT = "os"
class DG_OS_FOLDER(ENV):
"""
Path to parent directory for OS_DirGetter
"""
_name = "DG_OS_FOLDER"
DEFAULT = "/mnt/Media"
class DG_JELLYFIN_HOST(ENV):
"""
Jellyfin Server API host
"""
_name = "DG_JELLYFIN_HOST"
DEFAULT = "http://localhost:8096"
class DG_JELLYFIN_TOKEN(ENV):
"""
Jellyfin API key
"""
_name = "DG_JELLYFIN_TOKEN"
DEFAULT = ""
class TORRENT_SERVER(ENV):
"""
Torrent Server Type
"""
_name = "TORRENT_SERVER"
DEFAULT = "qbit"
class TS_USER(ENV):
"""
Torrent Server auth username
"""
_name = "TS_USER"
DEFAULT = ""
class TS_PASSWORD(ENV):
"""
Torrent Server auth password
"""
_name = "TS_PASSWORD"
DEFAULT = ""
class TS_HOST(ENV):
"""
Torrent Server host
"""
_name = "TS_HOST"
DEFAULT = "http://localhost"
class AUTH_PASSWD(ENV):
"""
Password for users auth
"""
_name = "AUTH_PASSWD"
DEFAULT = "changeme"

View File

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
##################
# ExceptionTypes #
##################
class ValidationError(Exception):
"""
Validation error exception
"""
class AuthError(Exception):
"""
Authentification error exception
"""
class AlreadyExists(Exception):
"""
Object already exists error exception
"""

View File

@ -6,6 +6,7 @@
# Imports
from tubot.static.abc import IValidatable
from tubot.static.exceptions import ValidationError
async def validate(obj: IValidatable, msg: str | None = None) -> bool:
@ -17,5 +18,5 @@ async def validate(obj: IValidatable, msg: str | None = None) -> bool:
if await obj.__validate__():
return True
if msg is None:
raise TypeError("Object validation failed")
raise TypeError(f"Object validation failed: {msg}")
raise ValidationError("Object validation failed")
raise ValidationError(f"Object validation failed: {msg}")

103
tubot/static/init.py Normal file
View File

@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
######################
# Init static module #
######################
# Imports
from typing import Iterable
from tubot.static import env
from tubot.static.functions import validate
from tubot.torrent.apis import qBitTorrent
from tubot.torrent.types import ServerTypes
from tubot.torrent.abc import TorrentAPI
from tubot.dirgetter.types import GetterTypes
from tubot.dirgetter.abc import DirGetter
from tubot.dirgetter.getter import OSGetter, Jellyfin
from tubot.db.types import CacheDBTypes
from tubot.db.abc import CacheDB
from tubot.db.cache import PythonCache, RedisCache
from asyncio import create_task, gather
class InitBuilder(object):
"""
Init all bot modules
"""
TORRENT_SERVER: ServerTypes | TorrentAPI
DG: GetterTypes | DirGetter
CACHE: CacheDBTypes | CacheDB
def set_torrent_server(self, server_type: ServerTypes) -> "InitBuilder":
self.TORRENT_SERVER = server_type
return self
def set_directory_getter(self, dg_type: GetterTypes) -> "InitBuilder":
self.DG = dg_type
return self
def set_cache_type(self, cache_type: CacheDBTypes) -> "InitBuilder":
self.CACHE = cache_type
return self
async def init_ts(self) -> None:
host = env.TS_HOST()()
user = env.TS_USER()()
pwd = env.TS_PASSWORD()()
match self.TORRENT_SERVER:
case ServerTypes.qBitTorrent:
self.TORRENT_SERVER = qBitTorrent(host, user, pwd)
case _:
raise TypeError
await validate(self.TORRENT_SERVER, "TorrentServerAPI validation error")
async def init_dg(self) -> None:
match self.DG:
case GetterTypes.OS:
base_dir = env.DG_OS_FOLDER()()
self.DG = OSGetter(base_dir)
case GetterTypes.Jellyfin:
host = env.DG_JELLYFIN_HOST()()
key = env.DG_JELLYFIN_TOKEN()()
self.DG = Jellyfin(host, key)
case _:
raise TypeError
await validate(self.DG, "DirectoryGetter validation error")
async def init_cache(self) -> None:
match self.CACHE:
case CacheDBTypes.PythonPKL:
self.CACHE = PythonCache()
case CacheDBTypes.Redis:
host = env.REDIS_HOST()()
self.CACHE = RedisCache(host)
case _:
raise TypeError
await validate(self.CACHE, "CacheDataBase validation error")
async def init_all_modules(self) -> None:
tasks = (
create_task(self.init_ts()),
create_task(self.init_dg()),
create_task(self.init_cache()),
)
await gather(*tasks)
@property
def tuple(self) -> Iterable:
return (self.TORRENT_SERVER, self.DG, self.CACHE)
async def init_modules() -> Iterable:
ts = ServerTypes(env.TORRENT_SERVER()())
dg = GetterTypes(env.DIR_GETTER()())
cache = CacheDBTypes(env.CACHE_TYPE()())
builder = (
InitBuilder()
.set_torrent_server(ts)
.set_directory_getter(dg)
.set_cache_type(cache)
)
await builder.init_all_modules()
return builder.tuple

View File

@ -11,6 +11,7 @@ from tubot.torrent.abc import TorrentAPI
from tubot.torrent.torrents import TorrentFile, TorrentMagnet, TorrentURL
from tubot.torrent.types import ServerTypes, TorrentListBuilder
from tubot.static.functions import validate
from tubot.static.exceptions import AuthError
class qBitTorrent(TorrentAPI):
@ -77,7 +78,7 @@ class qBitTorrent(TorrentAPI):
cookies = resp.cookies
resp = await self._get(api="api/v2/app/version", cookie=cookies)
if resp.status != 200:
raise ValueError("Auth error")
raise AuthError("Wrong creds")
self.cookie = cookies
return True
except Exception:
@ -85,7 +86,7 @@ class qBitTorrent(TorrentAPI):
return False
async def upload_file(self, torrent: TorrentFile) -> None:
await validate(self)
await validate(self, "Connection to TorrentServer failed")
await validate(torrent, "Bad .torrent file")
bytes = await torrent.getbytes()
data = FormData()
@ -99,20 +100,20 @@ class qBitTorrent(TorrentAPI):
await self._post("api/v2/torrents/add", cookie=self.cookie, data=data)
async def upload_magnet(self, torrent: TorrentMagnet) -> None:
await validate(self)
await validate(self, "Connection to TorrentServer failed")
await validate(torrent, "Bad magnet link")
data = {"urls": torrent.content, "savepath": torrent.dest}
await self._post("api/v2/torrents/add", cookie=self.cookie, data=data)
async def upload_url(self, torrent: TorrentURL) -> None:
await validate(self)
await validate(self, "Connection to TorrentServer failed")
await validate(torrent, "Bad url")
data = {"urls": torrent.content, "savepath": torrent.dest}
await self._post("api/v2/torrents/add", cookie=self.cookie, data=data)
@property
async def torrent_list(self) -> str:
await validate(self)
await validate(self, "Connection to TorrentServer failed")
responce = await self._get(
"api/v2/torrents/info?filter=completed,downloading&sort=progress",
cookie=self.cookie,

View File

@ -24,7 +24,7 @@ class ServerTypes(Enum):
Types of Torrent servers API's
"""
qBitTorrent = "qBitTorrent Remote API"
qBitTorrent = "qbit"
class TorrentFromServer(object):