Files
MediaManager/media_manager/main.py
2025-07-18 16:32:33 +10:00

409 lines
14 KiB
Python

import logging
import os
import sys
from logging.config import dictConfig
from pathlib import Path
from pythonjsonlogger.json import JsonFormatter
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "%(asctime)s - %(levelname)s - %(name)s - %(funcName)s(): %(message)s"
},
"json": {
"()": JsonFormatter,
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
"stream": sys.stdout,
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"formatter": "json",
"filename": "./log.txt",
"maxBytes": 10485760,
"backupCount": 5,
},
},
"loggers": {
"uvicorn": {"handlers": ["console", "file"], "level": "DEBUG"},
"uvicorn.access": {"handlers": ["console", "file"], "level": "DEBUG"},
"fastapi": {"handlers": ["console", "file"], "level": "DEBUG"},
},
}
dictConfig(LOGGING_CONFIG)
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(levelname)s - %(name)s - %(funcName)s(): %(message)s",
stream=sys.stdout,
)
log = logging.getLogger(__name__)
from media_manager.database import init_db # noqa: E402
from media_manager.config import AllEncompassingConfig # noqa: E402
import media_manager.torrent.router as torrent_router # noqa: E402
import media_manager.movies.router as movies_router # noqa: E402
import media_manager.tv.router as tv_router # noqa: E402
from media_manager.tv.service import ( # noqa: E402
auto_download_all_approved_season_requests,
import_all_show_torrents,
update_all_non_ended_shows_metadata,
)
from media_manager.movies.service import ( # noqa: E402
import_all_movie_torrents,
update_all_movies_metadata,
)
from media_manager.notification.router import router as notification_router # noqa: E402
import uvicorn # noqa: E402
from fastapi.staticfiles import StaticFiles # noqa: E402
from media_manager.auth.users import openid_client # noqa: E402
from media_manager.auth.users import SECRET as AUTH_USERS_SECRET # noqa: E402
from media_manager.auth.router import users_router as custom_users_router # noqa: E402
from media_manager.auth.router import auth_metadata_router # noqa: E402
from media_manager.auth.schemas import UserCreate, UserRead, UserUpdate # noqa: E402
from media_manager.auth.oauth import get_oauth_router # noqa: E402
from media_manager.auth.users import ( # noqa: E402
bearer_auth_backend,
fastapi_users,
cookie_auth_backend,
openid_cookie_auth_backend,
)
from media_manager.exceptions import ( # noqa: E402
NotFoundError,
not_found_error_exception_handler,
MediaAlreadyExists,
media_already_exists_exception_handler,
InvalidConfigError,
invalid_config_error_exception_handler,
)
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore # noqa: E402
from starlette.responses import FileResponse, RedirectResponse # noqa: E402
import media_manager.database # noqa: E402
import shutil # noqa: E402
from fastapi import FastAPI, APIRouter # noqa: E402
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
from starlette.responses import Response # noqa: E402
from datetime import datetime # noqa: E402
from contextlib import asynccontextmanager # noqa: E402
from apscheduler.schedulers.background import BackgroundScheduler # noqa: E402
from apscheduler.triggers.cron import CronTrigger # noqa: E402
init_db()
log.info("Database initialized")
config = AllEncompassingConfig()
# Create application-specific directories that the app manages
log.info("Creating application directories...")
config.misc.image_directory.mkdir(parents=True, exist_ok=True)
log.info("Application directories created successfully")
# Note: Media directories (tv, movies, torrents) are user-configured and mounted as volumes
# The application doesn't create these as they should be managed by the user
if config.misc.development:
log.warning("Development Mode activated!")
else:
log.info("Development Mode not activated!")
def hourly_tasks():
log.info(f"Hourly tasks are running at {datetime.now()}")
auto_download_all_approved_season_requests()
import_all_show_torrents()
import_all_movie_torrents()
def weekly_tasks():
log.info(f"Weekly tasks are running at {datetime.now()}")
update_all_non_ended_shows_metadata()
update_all_movies_metadata()
jobstores = {"default": SQLAlchemyJobStore(engine=media_manager.database.engine)}
scheduler = BackgroundScheduler(jobstores=jobstores)
every_15_minutes_trigger = CronTrigger(minute="*/15", hour="*")
daily_trigger = CronTrigger(hour=0, minute=0, jitter=60 * 60 * 24 * 2)
weekly_trigger = CronTrigger(
day_of_week="mon", hour=0, minute=0, jitter=60 * 60 * 24 * 2
)
scheduler.add_job(import_all_movie_torrents, every_15_minutes_trigger)
scheduler.add_job(import_all_show_torrents, every_15_minutes_trigger)
scheduler.add_job(auto_download_all_approved_season_requests, daily_trigger)
scheduler.add_job(update_all_movies_metadata, weekly_trigger)
scheduler.add_job(update_all_non_ended_shows_metadata, weekly_trigger)
scheduler.start()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: Create default admin user if needed
await create_default_admin_user()
yield
# Shutdown
scheduler.shutdown()
async def create_default_admin_user():
"""Create a default admin user if no users exist in the database"""
try:
from media_manager.auth.db import get_user_db
from media_manager.auth.users import get_user_manager
from media_manager.auth.schemas import UserCreate
from media_manager.database import get_session
from sqlalchemy import select, func
from media_manager.auth.db import User
async for session in get_session():
async for user_db in get_user_db(session):
async for user_manager in get_user_manager(user_db):
# Check if any users exist
result = await session.execute(select(func.count(User.id)))
user_count = result.scalar()
if user_count == 0:
log.info("No users found in database. Creating default admin user...")
# Use the first admin email from config, or default
admin_email = config.auth.admin_emails[0] if config.auth.admin_emails else "admin@mediamanager.local"
default_password = "admin" # Simple default password
user_create = UserCreate(
email=admin_email,
password=default_password,
is_superuser=True,
is_verified=True
)
user = await user_manager.create(user_create)
log.info("=" * 60)
log.info("✅ DEFAULT ADMIN USER CREATED!")
log.info(f" 📧 Email: {admin_email}")
log.info(f" 🔑 Password: {default_password}")
log.info(f" 👤 User ID: {user.id}")
log.info(" ⚠️ IMPORTANT: Please change this password after first login!")
log.info("=" * 60)
else:
log.info(f"Found {user_count} existing users. Skipping default user creation.")
break
break
break
except Exception as e:
log.error(f"Failed to create default admin user: {e}")
log.info("You can create an admin user manually by registering with an email from the admin_emails list in your config.")
BASE_PATH = os.getenv("BASE_PATH", "")
FRONTEND_FILES_DIR = os.getenv("FRONTEND_FILES_DIR")
app = FastAPI(lifespan=lifespan, root_path=BASE_PATH)
origins = config.misc.cors_urls
log.info("CORS URLs activated for following origins:")
for origin in origins:
log.info(f" - {origin}")
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
api_app = APIRouter(prefix="/api/v1")
# ----------------------------
# Hello World Router
# ----------------------------
@api_app.get("/health")
async def hello_world() -> dict:
"""
A simple endpoint to check if the API is running.
"""
return {"message": "Hello World!", "version": os.getenv("PUBLIC_VERSION")}
# ----------------------------
# Standard Auth Routers
# ----------------------------
api_app.include_router(
fastapi_users.get_auth_router(bearer_auth_backend),
prefix="/auth/jwt",
tags=["auth"],
)
api_app.include_router(
fastapi_users.get_auth_router(cookie_auth_backend),
prefix="/auth/cookie",
tags=["auth"],
)
api_app.include_router(
fastapi_users.get_register_router(UserRead, UserCreate),
prefix="/auth",
tags=["auth"],
)
api_app.include_router(
fastapi_users.get_reset_password_router(),
prefix="/auth",
tags=["auth"],
)
api_app.include_router(
fastapi_users.get_verify_router(UserRead),
prefix="/auth",
tags=["auth"],
)
# ----------------------------
# User Management Routers
# ----------------------------
api_app.include_router(custom_users_router, tags=["users"])
api_app.include_router(
fastapi_users.get_users_router(UserRead, UserUpdate),
prefix="/users",
tags=["users"],
)
# ----------------------------
# OpenID Connect Routers
# ----------------------------
api_app.include_router(auth_metadata_router, tags=["openid"])
if openid_client is not None:
api_app.include_router(
get_oauth_router(
oauth_client=openid_client,
backend=openid_cookie_auth_backend,
get_user_manager=fastapi_users.get_user_manager,
state_secret=AUTH_USERS_SECRET,
associate_by_email=True,
is_verified_by_default=True,
),
prefix=f"/auth/cookie/{openid_client.name}",
tags=["openid"],
)
api_app.include_router(tv_router.router, prefix="/tv", tags=["tv"])
api_app.include_router(torrent_router.router, prefix="/torrent", tags=["torrent"])
api_app.include_router(movies_router.router, prefix="/movies", tags=["movie"])
api_app.include_router(
notification_router, prefix="/notification", tags=["notification"]
)
app.mount(
"/api/v1/static/image",
StaticFiles(directory=config.misc.image_directory),
name="static-images",
)
app.include_router(api_app)
app.mount("/web", StaticFiles(directory=FRONTEND_FILES_DIR, html=True), name="frontend")
# Add root route to redirect to web UI
@app.get("/")
async def root():
return RedirectResponse(url="/web/")
# Add common routes that users might try
@app.get("/dashboard")
async def dashboard():
return RedirectResponse(url="/web/")
@app.get("/login")
async def login():
return RedirectResponse(url="/web/")
# ----------------------------
# Custom Exception Handlers
# ----------------------------
app.add_exception_handler(NotFoundError, not_found_error_exception_handler)
app.add_exception_handler(MediaAlreadyExists, media_already_exists_exception_handler)
app.add_exception_handler(InvalidConfigError, invalid_config_error_exception_handler)
@app.exception_handler(404)
async def not_found_handler(request, exc):
if any(
base_path in ["/web", "/dashboard", "/login"] for base_path in request.url.path
):
return FileResponse(f"{FRONTEND_FILES_DIR}/404.html")
return Response(content="Not Found", status_code=404)
# ----------------------------
# Hello World
# ----------------------------
log.info("Hello World!")
# ----------------------------
# Startup filesystem checks
# ----------------------------
try:
test_dir = config.misc.tv_directory / Path(".media_manager_test_dir")
test_dir.mkdir(parents=True, exist_ok=True)
test_dir.rmdir()
log.info(f"Successfully created test dir in TV directory at: {test_dir}")
test_dir = config.misc.movie_directory / Path(".media_manager_test_dir")
test_dir.mkdir(parents=True, exist_ok=True)
test_dir.rmdir()
log.info(f"Successfully created test dir in Movie directory at: {test_dir}")
test_dir = config.misc.image_directory / Path(".media_manager_test_dir")
test_dir.touch()
test_dir.unlink()
log.info(f"Successfully created test file in Image directory at: {test_dir}")
# check if hardlink creation works
test_dir = config.misc.tv_directory / Path(".media_manager_test_dir")
test_dir.mkdir(parents=True, exist_ok=True)
torrent_dir = config.misc.torrent_directory / Path(".media_manager_test_dir")
torrent_dir.mkdir(parents=True, exist_ok=True)
test_torrent_file = torrent_dir / Path(".media_manager.test.torrent")
test_torrent_file.touch()
test_hardlink = test_dir / Path(".media_manager.test.hardlink")
try:
test_hardlink.hardlink_to(test_torrent_file)
if not test_hardlink.samefile(test_torrent_file):
log.critical("Hardlink creation failed!")
log.info("Successfully created test hardlink in TV directory")
except OSError as e:
log.error(
f"Hardlink creation failed, falling back to copying files. Error: {e}"
)
shutil.copy(src=test_torrent_file, dst=test_hardlink)
finally:
test_hardlink.unlink()
test_torrent_file.unlink()
torrent_dir.rmdir()
test_dir.rmdir()
except Exception as e:
log.error(f"Error creating test directory: {e}")
raise
if __name__ == "__main__":
# TODO: add feature to run app with different root_path
uvicorn.run(app, host="127.0.0.1", port=5049, log_config=LOGGING_CONFIG)