Merge pull request #4

refactor code
This commit is contained in:
maxDorninger
2025-03-28 15:30:31 +01:00
committed by GitHub
36 changed files with 141 additions and 192 deletions

View File

@@ -1,15 +0,0 @@
FROM mcr.microsoft.com/devcontainers/python:1-3.11-bullseye
ENV PYTHONUNBUFFERED 1
# [Optional] If your requirements rarely change, uncomment this section to add them to the image.
# COPY requirements.txt /tmp/pip-tmp/
# RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \
# && rm -rf /tmp/pip-tmp
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>

View File

@@ -1,31 +0,0 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/postgres
{
"name": "Python 3 & PostgreSQL",
"dockerComposeFile": "./docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// This can be used to network with other containers or the host.
// "forwardPorts": [5000, 5432],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "pip install --user -r ./MediaManager/src/requirements.txt",
// Configure tool-specific properties.
"customizations" : {
"jetbrains" : {
"settings": {
"com.intellij:app:HttpConfigurable.use_proxy_pac": true
},
"backend" : "PyCharm"
}
},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

4
.gitignore vendored
View File

@@ -1,6 +1,6 @@
.idea
venv
MediaManager.iml
MediaManager/res
MediaManager/res/.env
backend/res
backend/res/.env
docker-compose.yml

View File

@@ -1,60 +0,0 @@
import os
from typing import Literal
from pydantic import BaseModel
class DbConfig(BaseModel):
host: str = os.getenv("DB_HOST") or "localhost"
port: int = int(os.getenv("DB_PORT") or 5432)
user: str = os.getenv("DB_USERNAME") or "MediaManager"
_password: str = os.getenv("DB_PASSWORD") or "MediaManager"
dbname: str = os.getenv("DB_NAME") or "MediaManager"
@property
def password(self):
return self._password
class TmdbConfig(BaseModel):
api_key: str = os.getenv("TMDB_API_KEY") or None
class BasicConfig(BaseModel):
storage_directory: str = os.getenv("STORAGE_FILE_PATH") or "."
class ProwlarrConfig(BaseModel):
enabled: bool = bool(os.getenv("PROWLARR_ENABLED") or True)
api_key: str = os.getenv("PROWLARR_API_KEY")
url: str = os.getenv("PROWLARR_URL")
class AuthConfig(BaseModel):
# to get a signing key run:
# openssl rand -hex 32
_jwt_signing_key: str = os.getenv("JWT_SIGNING_KEY")
jwt_signing_algorithm: str = "HS256"
jwt_access_token_lifetime: int = int(os.getenv("JWT_ACCESS_TOKEN_LIFETIME") or 60 * 24 * 30)
@property
def jwt_signing_key(self):
return self._jwt_signing_key
class QbittorrentConfig(BaseModel):
host: str = os.getenv("QBITTORRENT_HOST") or "localhost"
port: int = os.getenv("QBITTORRENT_PORT") or 8080
username: str = os.getenv("QBITTORRENT_USERNAME") or "admin"
password: str = os.getenv("QBITTORRENT_PASSWORD") or "adminadmin"
class DownloadClientConfig(BaseModel):
client: Literal['qbit'] = os.getenv("DOWNLOAD_CLIENT") or "qbit"
class MachineLearningConfig(BaseModel):
model_name: str = os.getenv("OLLAMA_MODEL_NAME") or "qwen2.5:0.5b"
def get_db_config() -> DbConfig:
return DbConfig()

View File

@@ -1,10 +0,0 @@
from config import DownloadClientConfig
from dowloadClients.qbittorrent import QbittorrentClient
config = DownloadClientConfig()
# TODO: add more elif when implementing more download clients
if config.client == "qbit":
client = QbittorrentClient()
else:
client = QbittorrentClient()

View File

@@ -2,16 +2,17 @@ import logging
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import Depends, HTTPException, status, APIRouter
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jwt.exceptions import InvalidTokenError
from pydantic import BaseModel
from config import AuthConfig
from database import SessionDependency
from auth.config import AuthConfig
from database import DbSessionDependency
from database.users import User
# TODO: evaluate FASTAPI-Users package
class Token(BaseModel):
access_token: str
@@ -29,20 +30,20 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/token")
router = APIRouter()
async def get_current_user(db: SessionDependency, token: str = Depends(oauth2_scheme)) -> User:
async def get_current_user(db: DbSessionDependency, token: str = Depends(oauth2_scheme)) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
config = AuthConfig()
auth_config = AuthConfig
log.debug("token: " + token)
try:
payload = jwt.decode(token, config.jwt_signing_key, algorithms=[config.jwt_signing_algorithm])
payload = jwt.decode(token, auth_config.jwt_signing_key, algorithms=[auth_config.jwt_signing_algorithm])
log.debug("jwt payload: " + payload.__str__())
user_uid: str = payload.get("sub")
log.debug("jwt payload sub (user uid): " + user_uid)
log.debug("jwt payload sub (USER uid): " + user_uid)
if user_uid is None:
raise credentials_exception
token_data = TokenData(uid=user_uid)
@@ -53,20 +54,20 @@ async def get_current_user(db: SessionDependency, token: str = Depends(oauth2_sc
user: User | None = db.get(User, token_data.uid)
if user is None:
log.debug("user not found")
log.debug("USER not found")
raise credentials_exception
log.debug("received user: " + user.__str__())
log.debug("received USER: " + user.__str__())
return user
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
config = AuthConfig()
auth_config = AuthConfig
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=config.jwt_access_token_lifetime)
expire = datetime.now(timezone.utc) + timedelta(minutes=auth_config.jwt_access_token_lifetime)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, config.jwt_signing_key, algorithm=config.jwt_signing_algorithm)
encoded_jwt = jwt.encode(to_encode, auth_config.jwt_signing_key, algorithm=auth_config.jwt_signing_algorithm)
return encoded_jwt

View File

@@ -0,0 +1,13 @@
from pydantic_settings import BaseSettings
class AuthConfig(BaseSettings):
# to get a signing key run:
# openssl rand -hex 32
jwt_signing_key: str
jwt_signing_algorithm: str = "HS256"
jwt_access_token_lifetime: int = 60 * 24 * 30
@property
def jwt_signing_key(self):
return self._jwt_signing_key

View File

@@ -1,15 +1,12 @@
from typing import Annotated
import hashlib
import bcrypt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session, select
from sqlmodel import select
import database
from auth import create_access_token, Token, router
from database import users, SessionDependency
from auth import Token, create_access_token, router
from database import DbSessionDependency
from database.users import User
@@ -24,12 +21,12 @@ def get_password_hash(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def authenticate_user(db: SessionDependency, email: str, password: str) -> bool | User:
def authenticate_user(db: DbSessionDependency, email: str, password: str) -> bool | User:
"""
:param email: email of the user
:param password: password of the user
:return: if authentication succeeds, returns the user object with added name and lastname, otherwise or if the user doesn't exist returns False
:param email: email of the USER
:param password: PASSWORD of the USER
:return: if authentication succeeds, returns the USER object with added name and lastname, otherwise or if the USER doesn't exist returns False
"""
user: User | None = db.exec(select(User).where(User.email == email)).first()
if not user:
@@ -42,13 +39,13 @@ def authenticate_user(db: SessionDependency, email: str, password: str) -> bool
@router.post("/token")
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: SessionDependency,
db: DbSessionDependency,
) -> Token:
user = authenticate_user(db,form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
detail="Incorrect email or PASSWORD",
headers={"WWW-Authenticate": "Bearer"},
)
# id needs to be converted because a UUID object isn't json serializable

5
backend/src/config.py Normal file
View File

@@ -0,0 +1,5 @@
from pydantic_settings import BaseSettings
class BasicConfig(BaseSettings):
storage_directory: str = "."

View File

@@ -4,14 +4,13 @@ from typing import Annotated, Any, Generator
from fastapi import Depends
from sqlmodel import SQLModel, Session, create_engine
import config
from config import DbConfig
from database.config import DbConfig
log = logging.getLogger(__name__)
config: DbConfig = config.get_db_config()
config = DbConfig()
db_url = "postgresql+psycopg" + "://" + config.user + ":" + config.password + "@" + config.host + ":" + str(
config.port) + "/" + config.dbname
db_url = "postgresql+psycopg" + "://" + config.USER + ":" + config.PASSWORD + "@" + config.HOST + ":" + str(
config.PORT) + "/" + config.DBNAME
engine = create_engine(db_url, echo=False)
@@ -24,5 +23,5 @@ def get_session() -> Generator[Session, Any, None]:
with Session(engine) as session:
yield session
SessionDependency = Annotated[Session, Depends(get_session)]
DbSessionDependency = Annotated[Session, Depends(get_session)]

View File

@@ -0,0 +1,10 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class DbConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix='DB')
HOST: str = "localhost"
PORT: int = 5432
USER: str = "MediaManager"
PASSWORD: str = "MediaManager"
DBNAME: str = "MediaManager"

View File

@@ -0,0 +1,9 @@
from dowloadClients.config import DownloadClientConfig
from dowloadClients.qbittorrent import QbittorrentClient
config = DownloadClientConfig()
if config.download_client == "qbit":
client = QbittorrentClient()
else:
raise ValueError("Unknown download client")

View File

@@ -0,0 +1,5 @@
from pydantic_settings import BaseSettings
class DownloadClientConfig(BaseSettings):
download_client: str = "qbit"

View File

@@ -9,7 +9,6 @@ class GenericDownloadClient(object):
raise ValueError('name cannot be None')
self.name = name
# TODO: change Torrents type to SeasonTorrents|MovieTorrents
def download(self, torrent: TorrentMixin) -> TorrentMixin:
"""
downloads a torrent

View File

@@ -1,13 +1,21 @@
import logging
import qbittorrentapi
from pydantic_settings import BaseSettings, SettingsConfigDict
from config import QbittorrentConfig
from database.torrents import Torrent
from dowloadClients.genericDownloadClient import GenericDownloadClient
log = logging.getLogger(__name__)
class QbittorrentConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix='QBITTORRENT_')
host: str = "localhost"
port: int = 8080
username: str = "admin"
class QbittorrentClient(GenericDownloadClient):
DOWNLOADING_STATE = ("allocating", "downloading", "metaDL", "pausedDL", "queuedDL", "stalledDL", "checkingDL",
"forcedDL", "moving")

View File

@@ -1,7 +1,7 @@
import logging
import config
from database.tv import Season
from indexer.config import ProwlarrConfig
from indexer.generic import GenericIndexer, IndexerQueryResult
from indexer.prowlarr import Prowlarr
@@ -23,5 +23,5 @@ def search(query: str | Season) -> list[IndexerQueryResult]:
indexers: list[GenericIndexer] = []
if config.ProwlarrConfig().enabled:
if ProwlarrConfig.enabled:
indexers.append(Prowlarr())

View File

@@ -0,0 +1,8 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class ProwlarrConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="PROWLARR_")
enabled: bool = True
api_key: str
url: str

View File

@@ -7,6 +7,7 @@ from pydantic import BaseModel, computed_field
from database.torrents import QualityMixin, Torrent
# TODO: use something like strategy pattern to make sorting more user customizable
class IndexerQueryResult(BaseModel, QualityMixin):
id: UUID = pydantic.Field(default_factory=uuid4)
title: str

View File

@@ -2,8 +2,8 @@ import logging
import requests
from config import ProwlarrConfig
from indexer import GenericIndexer, IndexerQueryResult
from indexer.config import ProwlarrConfig
log = logging.getLogger(__name__)
@@ -17,7 +17,7 @@ class Prowlarr(GenericIndexer):
:param kwargs: Additional keyword arguments to pass to the superclass constructor.
"""
super().__init__(name='prowlarr')
config = ProwlarrConfig()
config = ProwlarrConfig
self.api_key = config.api_key
self.url = config.url

View File

@@ -14,7 +14,7 @@ from fastapi import FastAPI
import database.users
import tv.router
from auth import password
from routers import users
from users import routers
LOGGING_CONFIG = {
"version": 1,
@@ -44,7 +44,7 @@ dictConfig(LOGGING_CONFIG)
database.init_db()
app = FastAPI(root_path="/api/v1")
app.include_router(users.router, tags=["users"])
app.include_router(routers.router, tags=["users"])
app.include_router(password.router, tags=["authentication"])
app.include_router(tv.router.router, tags=["tv"])

View File

@@ -7,7 +7,7 @@ from database.tv import Show
log = logging.getLogger(__name__)
class MetadataProvider(ABC):
class AbstractMetadataProvider(ABC):
storage_path = config.BasicConfig().storage_directory
@property
@abstractmethod
@@ -26,6 +26,6 @@ class MetadataProvider(ABC):
metadata_providers = {}
def register_metadata_provider(metadata_provider: MetadataProvider):
def register_metadata_provider(metadata_provider: AbstractMetadataProvider):
log.info("Registering metadata provider:" + metadata_provider.name)
metadata_providers[metadata_provider.name] = metadata_provider

View File

@@ -3,18 +3,24 @@ import mimetypes
import requests
import tmdbsimple
from pydantic_settings import BaseSettings
from tmdbsimple import TV, TV_Seasons
import config
from database.tv import Episode, Season, Show
from metadataProvider.abstractMetaDataProvider import MetadataProvider, register_metadata_provider
from metadataProvider.abstractMetaDataProvider import AbstractMetadataProvider, register_metadata_provider
config = config.TmdbConfig()
class TmdbConfig(BaseSettings):
TMDB_API_KEY: str | None = None
config = TmdbConfig
log = logging.getLogger(__name__)
class TmdbMetadataProvider(MetadataProvider):
class TmdbMetadataProvider(AbstractMetadataProvider):
name = "tmdb"
def get_show_metadata(self, id: int = None) -> Show:
"""
@@ -86,6 +92,6 @@ class TmdbMetadataProvider(MetadataProvider):
tmdbsimple.API_KEY = api_key
if config.api_key is not None:
if config.TMDB_API_KEY is not None:
log.info("Registering TMDB as metadata provider")
register_metadata_provider(metadata_provider=TmdbMetadataProvider(config.api_key))
register_metadata_provider(metadata_provider=TmdbMetadataProvider(config.TMDB_API_KEY))

View File

@@ -6,7 +6,7 @@ from typing import List
from ollama import ChatResponse, chat
from pydantic import BaseModel
import config
from ml.config import MachineLearningConfig
class NFO(BaseModel):
@@ -22,11 +22,11 @@ def get_season(nfo: str) -> int | None:
for i in range(0, 5):
responses.append(chat(
model=config.model_name,
model=config.ollama_model_name,
format=NFO.model_json_schema(),
messages=[
{
'role': 'user',
'role': 'USER',
'content':
"Tell me which season the torrent with this description contains?" +
" output a season number in json format, the season number is an integer" +
@@ -54,11 +54,11 @@ def contains_season(season_number: int, string_to_analyze: str) -> bool:
for i in range(0, 3):
responses.append(chat(
model=config.model_name,
model=config.ollama_model_name,
format=Contains.model_json_schema(),
messages=[
{
'role': 'user',
'role': 'USER',
'content':
"Does this torrent contain the season " + season_number.__str__() + " ?" +
" output a boolean json format" +
@@ -79,5 +79,6 @@ def contains_season(season_number: int, string_to_analyze: str) -> bool:
log.debug(f"according to AI {string_to_analyze} contains season {season_number} {most_common[0][0]}")
return most_common[0][0]
config = config.MachineLearningConfig()
config = MachineLearningConfig
log = logging.getLogger(__name__)

View File

@@ -1,8 +1,7 @@
import json
from datetime import datetime, timedelta
from ollama import ChatResponse
from ollama import chat
from ollama import ChatResponse, chat
from pydantic import BaseModel
@@ -19,7 +18,7 @@ while start_time > datetime.now():
format=NFO.model_json_schema()
, messages=[
{
'role': 'user',
'role': 'USER',
'content':
"which season does a torrent with the following NFO contain? output the season number, which is an integer in json please\n" +
"The.Big.Bang.Theory.(2007).Season.9.S09.(1080p.BluRay.x265.HEVC.10bit.AAC.5.1.Vyndros)"

5
backend/src/ml/config.py Normal file
View File

@@ -0,0 +1,5 @@
from pydantic_settings import BaseSettings
class MachineLearningConfig(BaseSettings):
ollama_model_name: str = "qwen2.5:0.5b"

View File

@@ -12,12 +12,12 @@ import auth
import dowloadClients
import indexer
import metadataProvider
from database import SessionDependency
from database import DbSessionDependency
from database.torrents import Torrent
from database.tv import Season, Show
from indexer import IndexerQueryResult
from routers.users import Message
from tv import log
from users.routers import Message
router = APIRouter(
prefix="/tv",
@@ -34,7 +34,7 @@ class ShowDetails(BaseModel):
status.HTTP_201_CREATED: {"model": Show, "description": "Successfully created show"},
status.HTTP_409_CONFLICT: {"model": Message, "description": "Show already exists"},
})
def add_show(db: SessionDependency, show_id: int, metadata_provider: str = "tmdb", version: str = ""):
def add_show(db: DbSessionDependency, show_id: int, metadata_provider: str = "tmdb", version: str = ""):
res = db.exec(select(Show).
where(Show.external_id == show_id).
where(Show.metadata_provider == metadata_provider).
@@ -59,14 +59,14 @@ def add_show(db: SessionDependency, show_id: int, metadata_provider: str = "tmdb
@router.delete("/{show_id}", status_code=status.HTTP_200_OK)
def delete_show(db: SessionDependency, show_id: UUID):
def delete_show(db: DbSessionDependency, show_id: UUID):
db.delete(db.get(Show, show_id))
db.commit()
@router.patch("/{show_id}/{season_id}", status_code=status.HTTP_200_OK, dependencies=[Depends(auth.get_current_user)],
response_model=Season)
def add_season(db: SessionDependency, season_id: UUID):
def add_season(db: DbSessionDependency, season_id: UUID):
"""
adds requested flag to a season
"""
@@ -81,7 +81,7 @@ def add_season(db: SessionDependency, season_id: UUID):
@router.delete("/{show_id}/{season_id}", status_code=status.HTTP_200_OK, dependencies=[Depends(auth.get_current_user)],
response_model=Show)
def delete_season(db: SessionDependency, show_id: UUID, season: int):
def delete_season(db: DbSessionDependency, show_id: UUID, season: int):
"""
removes requested flag from a season
"""
@@ -96,7 +96,7 @@ def delete_season(db: SessionDependency, show_id: UUID, season: int):
@router.get("/{show_id}/{season_id}/torrent", status_code=status.HTTP_200_OK, dependencies=[Depends(
auth.get_current_user)],
response_model=list[IndexerQueryResult])
def get_season_torrents(db: SessionDependency, show_id: UUID, season_id: UUID):
def get_season_torrents(db: DbSessionDependency, show_id: UUID, season_id: UUID):
season = db.get(Season, season_id)
if season is None:
@@ -121,7 +121,7 @@ def get_season_torrents(db: SessionDependency, show_id: UUID, season_id: UUID):
@router.post("/{show_id}/torrent", status_code=status.HTTP_200_OK, dependencies=[Depends(
auth.get_current_user)], response_model=list[Season])
def download_seasons_torrent(db: SessionDependency, show_id: UUID, torrent_id: UUID):
def download_seasons_torrent(db: DbSessionDependency, show_id: UUID, torrent_id: UUID):
"""
downloads torrents for a show season, links the torrent for all seasons the torrent contains
@@ -153,7 +153,7 @@ def download_seasons_torrent(db: SessionDependency, show_id: UUID, torrent_id: U
@router.post("/{show_id}/{season_id}/torrent", status_code=status.HTTP_200_OK, dependencies=[Depends(
auth.get_current_user)], response_model=list[Season])
def delete_seasons_torrent(db: SessionDependency, show_id: UUID, season_id: UUID, torrent_id: UUID):
def delete_seasons_torrent(db: DbSessionDependency, show_id: UUID, season_id: UUID, torrent_id: UUID):
"""
downloads torrents for a season, links the torrent only to the specified season
this means that multiple torrents can contain a season but you can choose from one which the content should be
@@ -186,13 +186,13 @@ def delete_seasons_torrent(db: SessionDependency, show_id: UUID, season_id: UUID
@router.get("/", dependencies=[Depends(auth.get_current_user)], response_model=list[Show])
def get_shows(db: SessionDependency):
def get_shows(db: DbSessionDependency):
""""""
return db.exec(select(Show)).unique().fetchall()
@router.get("/{show_id}", dependencies=[Depends(auth.get_current_user)], response_model=ShowDetails)
def get_show(db: SessionDependency, show_id: UUID):
def get_show(db: DbSessionDependency, show_id: UUID):
"""
:param show_id:

View File

@@ -1,14 +1,13 @@
from fastapi import APIRouter
from fastapi import Depends
from sqlalchemy.exc import IntegrityError
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError
from starlette.responses import JSONResponse
from auth import get_current_user
from auth.password import get_password_hash
from database import SessionDependency, get_session
from database import DbSessionDependency
from database.users import User, UserCreate, UserPublic
from routers import log
from users import log
router = APIRouter(
prefix="/users",
@@ -25,7 +24,7 @@ class Message(BaseModel):
201: {"model": UserPublic, "description": "User created successfully"}
})
async def create_user(
db: SessionDependency,
db: DbSessionDependency,
user: UserCreate = Depends(UserCreate),
):
internal_user = User(name=user.name, lastname=user.lastname, email=user.email,
@@ -35,9 +34,9 @@ async def create_user(
db.commit()
except IntegrityError as e:
log.debug(e)
log.warning("Failed to create new user, User with this email already exists "+internal_user.model_dump().__str__())
log.warning("Failed to create new USER, User with this email already exists " + internal_user.model_dump().__str__())
return JSONResponse(status_code=409, content={"message": "User with this email already exists"})
log.info("Created new user "+internal_user.email)
log.info("Created new USER " + internal_user.email)
return UserPublic(**internal_user.model_dump())