mirror of
https://github.com/maxdorninger/MediaManager.git
synced 2026-04-20 15:55:42 +02:00
- Move images directory from /data/images to /app/images to separate app data from user media - Implement config folder approach instead of direct file mounting - Add automatic config initialization with example config on first boot - Remove hardcoded media directory environment variables from Dockerfile - Update startup script to handle config folder setup and validation - Only create application-managed directories, not user media directories - Update docker-compose.yaml to use config folder volume mapping Fixes container startup failures when config.toml doesn't exist and improves separation between application data and user media directories.
342 lines
11 KiB
Python
342 lines
11 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 # 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):
|
|
yield
|
|
scheduler.shutdown()
|
|
|
|
|
|
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")
|
|
|
|
# ----------------------------
|
|
# 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)
|