diff --git a/.dockerignore b/.dockerignore index 0a31b95..f687cec 100755 --- a/.dockerignore +++ b/.dockerignore @@ -1,62 +1,36 @@ -# Git -.git -.gitignore -.gitattributes +# Allowlist minimal build context +* -# Docker -docker-compose.yaml -docker-compose.yml -Dockerfile -.dockerignore +# Backend +!requirements.txt +!app.py +!routes/** +# Re-ignore caches and compiled files inside routes +routes/**/__pycache__/ +routes/**/.pytest_cache/ +routes/**/*.pyc +routes/**/*.pyo -# Node -node_modules -spotizerr-ui/node_modules -npm-debug.log -pnpm-lock.yaml +# Frontend: only what's needed to build +!spotizerr-ui/package.json +!spotizerr-ui/pnpm-lock.yaml +!spotizerr-ui/pnpm-workspace.yaml +!spotizerr-ui/index.html +!spotizerr-ui/vite.config.ts +!spotizerr-ui/postcss.config.mjs +!spotizerr-ui/tsconfig.json +!spotizerr-ui/tsconfig.app.json +!spotizerr-ui/tsconfig.node.json +!spotizerr-ui/src/** +!spotizerr-ui/public/** +!spotizerr-ui/scripts/** +# Exclude heavy/unnecessary frontend folders +spotizerr-ui/node_modules/** +spotizerr-ui/dist/** +spotizerr-ui/dev-dist/** -# Python -__pycache__ -*.pyc -*.pyo -*.pyd -.Python -.env -.venv -venv/ -env/ -.env.example - -# Editor/OS -.vscode -.idea -.DS_Store -*.swp - -# Application data -credentials.json -test.py -downloads/ -creds/ -Test.py -prgs/ -flask_server.log -test.sh -routes/__pycache__/* -routes/utils/__pycache__/* -search_test.py -config/main.json -.cache -config/state/queue_state.json -output.log -queue_state.json -search_demo.py -celery_worker.log -static/js/* +# Always exclude local data/logs/tests/etc. +.data/ logs/ -data +Downloads/ tests/ - -# Non-essential files -docs/ -README.md diff --git a/.env.example b/.env.example index 7e3cd89..227381e 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,9 @@ ### -### Main configuration file of the server. If you -### plan to have this only for personal use, you +### Main configuration file of the server. If you +### plan to have this only for personal use, you ### can leave the defaults as they are. ### -### If you plan on using for a server, +### If you plan on using for a server, ### see [insert docs url] ### @@ -19,13 +19,7 @@ REDIS_PASSWORD=CHANGE_ME # Set to true to filter out explicit content. EXPLICIT_FILTER=false - - -# User and group ID for the container. Sets the owner of the downloaded files. -PUID=1000 -PGID=1000 - -# Optional: Sets the default file permissions for newly created files within the container. +# Optional: Sets the default file permissions for newly created files within the container. UMASK=0022 # Whether to setup file permissions on startup. May improve performance on remote/slow filesystems @@ -51,7 +45,7 @@ DEFAULT_ADMIN_PASSWORD=admin123 # Whether to allow new users to register themselves or leave that only available for admins DISABLE_REGISTRATION=false -# SSO Configuration +# SSO Configuration SSO_ENABLED=true SSO_BASE_REDIRECT_URI=http://127.0.0.1:7171/api/auth/sso/callback FRONTEND_URL=http://127.0.0.1:7171 diff --git a/Dockerfile b/Dockerfile index 739f4a8..09a719d 100755 --- a/Dockerfile +++ b/Dockerfile @@ -7,40 +7,59 @@ RUN pnpm install --frozen-lockfile COPY spotizerr-ui/. . RUN pnpm build -# Stage 2: Final application image -FROM python:3.12-slim +# Stage 2: Python dependencies builder (create relocatable deps dir) +FROM python:3.11-slim AS py-deps +WORKDIR /app +COPY requirements.txt . +COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/ +RUN uv pip install --target /python -r requirements.txt -# Set an environment variable for non-interactive frontend installation -ENV DEBIAN_FRONTEND=noninteractive +# Stage 3: Fetch static ffmpeg/ffprobe binaries +FROM debian:stable-slim AS ffmpeg +ARG TARGETARCH +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl xz-utils \ + && rm -rf /var/lib/apt/lists/* +RUN case "$TARGETARCH" in \ + amd64) FFMPEG_PKG=ffmpeg-master-latest-linux64-gpl.tar.xz ;; \ + arm64) FFMPEG_PKG=ffmpeg-master-latest-linuxarm64-gpl.tar.xz ;; \ + *) echo "Unsupported arch: $TARGETARCH" && exit 1 ;; \ + esac && \ + curl -fsSL -o /tmp/ffmpeg.tar.xz https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/${FFMPEG_PKG} && \ + tar -xJf /tmp/ffmpeg.tar.xz -C /tmp && \ + mv /tmp/ffmpeg-* /ffmpeg + +# Stage 4: Prepare world-writable runtime directories +FROM busybox:1.36.1-musl AS runtime-dirs +RUN mkdir -p /artifact/downloads /artifact/data/config /artifact/data/creds /artifact/data/watch /artifact/data/history /artifact/logs/tasks \ + && chmod -R 0777 /artifact + +# Stage 5: Final application image (distroless) +FROM gcr.io/distroless/python3-debian12 LABEL org.opencontainers.image.source="https://github.com/Xoconoch/spotizerr" WORKDIR /app -# Install system dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - ffmpeg gosu\ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* +# Ensure Python finds vendored site-packages and unbuffered output +ENV PYTHONPATH=/python +ENV PYTHONUNBUFFERED=1 -# Install Python dependencies -COPY requirements.txt . +# Copy application code +COPY --chown=65532:65532 . . -COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/ -RUN uv pip install --system -r requirements.txt +# Copy compiled assets from the frontend build +COPY --from=frontend-builder --chown=65532:65532 /app/spotizerr-ui/dist ./spotizerr-ui/dist -# Copy application code (excluding UI source and TS source) -COPY . . +# Copy vendored Python dependencies +COPY --from=py-deps --chown=65532:65532 /python /python -# Copy compiled assets from previous stages -COPY --from=frontend-builder /app/spotizerr-ui/dist ./spotizerr-ui/dist +# Copy static ffmpeg binaries +COPY --from=ffmpeg --chown=65532:65532 /ffmpeg/bin/ffmpeg /usr/local/bin/ffmpeg +COPY --from=ffmpeg --chown=65532:65532 /ffmpeg/bin/ffprobe /usr/local/bin/ffprobe -# Create necessary directories with proper permissions -RUN mkdir -p downloads data/config data/creds data/watch data/history logs/tasks && \ - chmod -R 777 downloads data logs +# Copy pre-created world-writable runtime directories +COPY --from=runtime-dirs --chown=65532:65532 /artifact/ ./ -# Make entrypoint script executable -RUN chmod +x entrypoint.sh - -# Set entrypoint to our script -ENTRYPOINT ["/app/entrypoint.sh"] +# No shell or package manager available in distroless +ENTRYPOINT ["python3", "app.py"] diff --git a/app.py b/app.py index c24c822..c2449b4 100755 --- a/app.py +++ b/app.py @@ -13,43 +13,14 @@ import redis import socket from urllib.parse import urlparse -# Run DB migrations as early as possible, before importing any routers that may touch DBs -try: - from routes.migrations import run_migrations_if_needed - - run_migrations_if_needed() - logging.getLogger(__name__).info( - "Database migrations executed (if needed) early in startup." - ) -except Exception as e: - logging.getLogger(__name__).error( - f"Database migration step failed early in startup: {e}", exc_info=True - ) - sys.exit(1) - -# Import route routers (to be created) -from routes.auth.credentials import router as credentials_router -from routes.auth.auth import router as auth_router -from routes.content.artist import router as artist_router -from routes.content.album import router as album_router -from routes.content.track import router as track_router -from routes.content.playlist import router as playlist_router -from routes.core.search import router as search_router -from routes.core.history import router as history_router -from routes.system.progress import router as prgs_router -from routes.system.config import router as config_router - - -# Import Celery configuration and manager -from routes.utils.celery_manager import celery_manager -from routes.utils.celery_config import REDIS_URL - -# Import authentication system -from routes.auth import AUTH_ENABLED -from routes.auth.middleware import AuthMiddleware - -# Import watch manager controls (start/stop) without triggering side effects -from routes.utils.watch.manager import start_watch_manager, stop_watch_manager +# Apply process umask from environment as early as possible +_umask_value = os.getenv("UMASK") +if _umask_value: + try: + os.umask(int(_umask_value, 8)) + except Exception: + # Defer logging setup; avoid failing on invalid UMASK + pass # Import and initialize routes (this will start the watch manager) @@ -61,6 +32,17 @@ def setup_logging(): logs_dir = Path("logs") logs_dir.mkdir(exist_ok=True) + # Ensure required runtime directories exist + for p in [ + Path("downloads"), + Path("data/config"), + Path("data/creds"), + Path("data/watch"), + Path("data/history"), + Path("logs/tasks"), + ]: + p.mkdir(parents=True, exist_ok=True) + # Set up log file paths main_log = logs_dir / "spotizerr.log" @@ -111,6 +93,8 @@ def setup_logging(): def check_redis_connection(): """Check if Redis is available and accessible""" + from routes.utils.celery_config import REDIS_URL + if not REDIS_URL: logging.error("REDIS_URL is not configured. Please check your environment.") return False @@ -156,6 +140,20 @@ async def lifespan(app: FastAPI): # Startup setup_logging() + # Run migrations before initializing services + try: + from routes.migrations import run_migrations_if_needed + + run_migrations_if_needed() + logging.getLogger(__name__).info( + "Database migrations executed (if needed) early in startup." + ) + except Exception as e: + logging.getLogger(__name__).error( + f"Database migration step failed early in startup: {e}", exc_info=True + ) + sys.exit(1) + # Check Redis connection if not check_redis_connection(): logging.error( @@ -165,6 +163,8 @@ async def lifespan(app: FastAPI): # Start Celery workers try: + from routes.utils.celery_manager import celery_manager + celery_manager.start() logging.info("Celery workers started successfully") except Exception as e: @@ -172,6 +172,8 @@ async def lifespan(app: FastAPI): # Start Watch Manager after Celery is up try: + from routes.utils.watch.manager import start_watch_manager, stop_watch_manager + start_watch_manager() logging.info("Watch Manager initialized and registered for shutdown.") except Exception as e: @@ -184,12 +186,16 @@ async def lifespan(app: FastAPI): # Shutdown try: + from routes.utils.watch.manager import start_watch_manager, stop_watch_manager + stop_watch_manager() logging.info("Watch Manager stopped") except Exception as e: logging.error(f"Error stopping Watch Manager: {e}") try: + from routes.utils.celery_manager import celery_manager + celery_manager.stop() logging.info("Celery workers stopped") except Exception as e: @@ -215,13 +221,30 @@ def create_app(): ) # Add authentication middleware (only if auth is enabled) - if AUTH_ENABLED: - app.add_middleware(AuthMiddleware) - logging.info("Authentication system enabled") - else: - logging.info("Authentication system disabled") + try: + from routes.auth import AUTH_ENABLED + from routes.auth.middleware import AuthMiddleware + + if AUTH_ENABLED: + app.add_middleware(AuthMiddleware) + logging.info("Authentication system enabled") + else: + logging.info("Authentication system disabled") + except Exception as e: + logging.warning(f"Auth system initialization failed or unavailable: {e}") # Register routers with URL prefixes + from routes.auth.auth import router as auth_router + from routes.system.config import router as config_router + from routes.core.search import router as search_router + from routes.auth.credentials import router as credentials_router + from routes.content.album import router as album_router + from routes.content.track import router as track_router + from routes.content.playlist import router as playlist_router + from routes.content.artist import router as artist_router + from routes.system.progress import router as prgs_router + from routes.core.history import router as history_router + app.include_router(auth_router, prefix="/api/auth", tags=["auth"]) # Include SSO router if available diff --git a/docker-compose.yaml b/docker-compose.yaml index d7bba01..e1ad5af 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,7 @@ name: spotizerr services: spotizerr: - image: cooldockerizer93/spotizerr + image: cooldockerizer93/spotizerr:beta volumes: - ./data:/app/data - ./downloads:/app/downloads @@ -9,6 +9,7 @@ services: ports: - 7171:7171 container_name: spotizerr-app + user: "1000:1000" # spotizerr user:group ids restart: unless-stopped env_file: - .env diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100755 index cf00504..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/bin/bash -set -e - -# Set umask if UMASK variable is provided -if [ -n "${UMASK}" ]; then - umask "${UMASK}" -fi - -# Compose Redis URLs from base variables if not explicitly provided -if [ -z "${REDIS_URL}" ]; then - REDIS_HOST=${REDIS_HOST:-redis} - REDIS_PORT=${REDIS_PORT:-6379} - REDIS_DB=${REDIS_DB:-0} - - if [ -n "${REDIS_PASSWORD}" ]; then - if [ -n "${REDIS_USERNAME}" ]; then - AUTH_PART="${REDIS_USERNAME}:${REDIS_PASSWORD}@" - else - AUTH_PART=":${REDIS_PASSWORD}@" - fi - else - AUTH_PART="" - fi - export REDIS_URL="redis://${AUTH_PART}${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB}" -fi - -if [ -z "${REDIS_BACKEND}" ]; then - export REDIS_BACKEND="${REDIS_URL}" -fi - -# Redis is now in a separate container so we don't need to start it locally -echo "Using Redis at ${REDIS_URL}" - -# Check if both PUID and PGID are not set -if [ -z "${PUID}" ] && [ -z "${PGID}" ]; then - # Run as root directly - echo "Running as root user (no PUID/PGID specified)" - exec python app.py -else - # Verify both PUID and PGID are set - if [ -z "${PUID}" ] || [ -z "${PGID}" ]; then - echo "ERROR: Must supply both PUID and PGID or neither" - exit 1 - fi - - # Check for root user request - if [ "${PUID}" -eq 0 ] && [ "${PGID}" -eq 0 ]; then - echo "Running as root user (PUID/PGID=0)" - exec python app.py - else - # Check if the group with the specified GID already exists - if getent group "${PGID}" >/dev/null; then - # If the group exists, use its name instead of creating a new one - GROUP_NAME=$(getent group "${PGID}" | cut -d: -f1) - echo "Using existing group: ${GROUP_NAME} (GID: ${PGID})" - else - # If the group doesn't exist, create it - GROUP_NAME="appgroup" - groupadd -g "${PGID}" "${GROUP_NAME}" - echo "Created group: ${GROUP_NAME} (GID: ${PGID})" - fi - - # Check if the user with the specified UID already exists - if getent passwd "${PUID}" >/dev/null; then - # If the user exists, use its name instead of creating a new one - USER_NAME=$(getent passwd "${PUID}" | cut -d: -f1) - echo "Using existing user: ${USER_NAME} (UID: ${PUID})" - else - # If the user doesn't exist, create it - USER_NAME="appuser" - useradd -u "${PUID}" -g "${GROUP_NAME}" -d /app "${USER_NAME}" - echo "Created user: ${USER_NAME} (UID: ${PUID})" - fi - - # Ensure proper permissions for all app directories unless skipped via env var - if [ "${SKIP_SET_PERMISSIONS}" = "true" ] || [ "${SKIP_SET_PERMISSIONS}" = "1" ]; then - echo "SKIP_SET_PERMISSIONS is set; skipping permissions for /app/downloads /app/data /app/logs" - else - echo "Setting permissions for /app directories..." - chown -R "${USER_NAME}:${GROUP_NAME}" /app/downloads /app/data /app/logs || true - fi - - # Ensure Spotipy cache file exists and is writable (fast, local to container) - touch /app/.cache || true - chown "${USER_NAME}:${GROUP_NAME}" /app/.cache || true - - # Run as specified user - echo "Starting application as ${USER_NAME}..." - exec gosu "${USER_NAME}" python app.py - fi -fi \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 0e42ced..74eea53 100755 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,5 @@ bcrypt==4.2.1 PyJWT==2.10.1 python-multipart==0.0.17 fastapi-sso==0.18.0 +redis==5.0.7 +async-timeout==4.0.3