├── .devcontainer ├── Dockerfile ├── dev-requirements.txt ├── dev-start.sh └── devcontainer.json ├── .dockerignore ├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question-help-wanted.md ├── dependabot.yml ├── release.yml └── workflows │ ├── docker-build.yml │ ├── docker-publish-nightly.yml │ ├── docker-publish.yml │ ├── docs-build.yml │ └── update-dockerhub-description.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── images │ ├── trailarr-128.png │ ├── trailarr-192.png │ ├── trailarr-256.png │ ├── trailarr-48.png │ ├── trailarr-512-lg.ico │ ├── trailarr-512.ico │ ├── trailarr-512.png │ ├── trailarr-64.png │ ├── trailarr-96.png │ ├── trailarr-full-512-lg.png │ ├── trailarr-full-512.png │ ├── trailarr-full-dark.svg │ ├── trailarr-full-light-512-lg.png │ ├── trailarr-full-light-512.png │ ├── trailarr-full-light.svg │ ├── trailarr-full-primary-512-lg.png │ ├── trailarr-full-primary-512.png │ ├── trailarr-full.svg │ ├── trailarr-maskable-192.png │ ├── trailarr-maskable-512.png │ ├── trailarr-maskable-lg.png │ ├── trailarr-text.svg │ └── trailarr.svg └── openapi │ ├── redoc.standalone.js │ ├── swagger-dark-ui.css │ ├── swagger-ui-bundle.js │ └── swagger-ui.css ├── backend ├── .coveragerc ├── __init__.py ├── alembic.ini ├── alembic │ ├── README.md │ ├── env.py │ ├── script.py.mako │ └── versions │ │ ├── 20240515_2108-325a4fb01c20_initialdev.py │ │ ├── 20240813_1709-34f937f1d7b9_movie_and_series_to_media.py │ │ ├── 20240917_1327-1cc1dd5dbe9f_add_media_status.py │ │ ├── 20250209_0142-4b942197af6a_extra_options_in_media.py │ │ ├── 20250217_1144-1c5ac69def9a_add_customfilter.py │ │ └── 20250502_0642-656a0b9cbe50_trailerprofile_implementation.py ├── api │ ├── __init__.py │ └── v1 │ │ ├── __init__.py │ │ ├── authentication.py │ │ ├── connections.py │ │ ├── customfilters.py │ │ ├── files.py │ │ ├── logs.py │ │ ├── media.py │ │ ├── models.py │ │ ├── routes.py │ │ ├── settings.py │ │ ├── tasks.py │ │ ├── trailerprofiles.py │ │ └── websockets.py ├── app_logger.py ├── config │ ├── __init__.py │ ├── app_logger_opts.py │ ├── logger_config.json │ └── settings.py ├── core │ ├── __init__.py │ ├── base │ │ ├── __init__.py │ │ ├── arr_manager │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ └── request_manager.py │ │ ├── connection_manager.py │ │ ├── database │ │ │ ├── __init__.py │ │ │ ├── manager │ │ │ │ ├── __init__.py │ │ │ │ ├── base.py │ │ │ │ ├── connection.py │ │ │ │ ├── customfilter │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── base.py │ │ │ │ │ ├── create.py │ │ │ │ │ ├── delete.py │ │ │ │ │ ├── read.py │ │ │ │ │ └── update.py │ │ │ │ ├── general.py │ │ │ │ ├── media │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── base.py │ │ │ │ │ ├── create_update.py │ │ │ │ │ ├── delete.py │ │ │ │ │ ├── read.py │ │ │ │ │ └── search.py │ │ │ │ └── trailerprofile │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── base.py │ │ │ │ │ ├── create.py │ │ │ │ │ ├── delete.py │ │ │ │ │ ├── read.py │ │ │ │ │ └── update.py │ │ │ ├── models │ │ │ │ ├── __init__.py │ │ │ │ ├── connection.py │ │ │ │ ├── customfilter.py │ │ │ │ ├── filter.py │ │ │ │ ├── helpers.py │ │ │ │ ├── media.py │ │ │ │ └── trailerprofile.py │ │ │ └── utils │ │ │ │ ├── __init__.py │ │ │ │ ├── engine.py │ │ │ │ └── init_db.py │ │ └── utils │ │ │ ├── __init__.py │ │ │ └── filters.py │ ├── download │ │ ├── __init__.py │ │ ├── cli.py │ │ ├── image.py │ │ ├── trailer.py │ │ ├── trailer_file.py │ │ ├── trailer_search.py │ │ ├── trailers │ │ │ ├── __init__.py │ │ │ ├── batch.py │ │ │ └── missing.py │ │ ├── video_analysis.py │ │ ├── video_conversion.py │ │ └── video_v2.py │ ├── files_handler.py │ ├── radarr │ │ ├── __init__.py │ │ ├── api_manager.py │ │ ├── connection_manager.py │ │ ├── data_parser.py │ │ ├── database_manager.py │ │ └── models.py │ ├── sonarr │ │ ├── __init__.py │ │ ├── api_manager.py │ │ ├── connection_manager.py │ │ ├── data_parser.py │ │ ├── database_manager.py │ │ └── models.py │ ├── tasks │ │ ├── __init__.py │ │ ├── api_refresh.py │ │ ├── cleanup.py │ │ ├── download_trailers.py │ │ ├── files_scan.py │ │ ├── image_refresh.py │ │ ├── schedules.py │ │ └── task_logging.py │ └── updates │ │ ├── __init__.py │ │ └── docker_check.py ├── exceptions.py ├── export_openapi.py ├── main.py ├── requirements.txt └── tests │ ├── __init__.py │ ├── api │ └── __init__.py │ ├── config │ ├── __init__.py │ └── test_config.py │ ├── conftest.py │ ├── database │ ├── __init__.py │ ├── crud │ │ ├── __init__.py │ │ ├── test_connection.py │ │ ├── test_movie.py │ │ └── test_series.py │ └── utils │ │ ├── __init__.py │ │ └── test_init_db.py │ └── services │ ├── __init__.py │ └── arr_manager │ ├── __init__.py │ ├── test_async_request_manager.py │ ├── test_base.py │ ├── test_radarr.py │ └── test_sonarr.py ├── docs ├── CONTRIBUTING.md ├── help │ ├── api │ │ ├── docs.html │ │ ├── openapi.json │ │ └── swagger-dark-ui.css │ ├── changelog.md │ ├── common.md │ ├── docker-builder │ │ ├── builder.css │ │ ├── builder.html │ │ └── builder.js │ ├── faq.md │ ├── legal-disclaimer.md │ └── release-notes │ │ ├── 2024.md │ │ └── 2025.md ├── index.md ├── install.md ├── install │ ├── docker-cli.md │ ├── docker-compose.md │ ├── env-variables.md │ ├── hardware-acceleration.md │ ├── radarr-mapping.png │ ├── sonarr-mapping.png │ └── volume-mapping.md ├── setup │ ├── add-connection.png │ ├── add-new.png │ ├── add-path-mapping.png │ ├── connections.md │ ├── path-mapping.png │ └── settings.md └── win_mounts.txt ├── frontend-build ├── 3rdpartylicenses.txt ├── browser │ ├── assets │ │ ├── IMDBlogo.png │ │ ├── TMDBlogo.png │ │ ├── TVDBlogo.png │ │ ├── icons │ │ │ ├── home.svg │ │ │ ├── movies.svg │ │ │ ├── series.svg │ │ │ └── settings.svg │ │ ├── logos │ │ │ ├── trailarr-192.ico │ │ │ ├── trailarr-192.png │ │ │ ├── trailarr-256.ico │ │ │ ├── trailarr-48.ico │ │ │ ├── trailarr-48.png │ │ │ ├── trailarr-512.png │ │ │ ├── trailarr-96.ico │ │ │ ├── trailarr-96.png │ │ │ ├── trailarr-full-512.png │ │ │ ├── trailarr-full-dark.svg │ │ │ ├── trailarr-full-light-512.png │ │ │ ├── trailarr-full-light.svg │ │ │ ├── trailarr-full.svg │ │ │ ├── trailarr-maskable-192.png │ │ │ ├── trailarr-maskable-512.png │ │ │ └── trailarr.svg │ │ ├── manifest.json │ │ ├── poster-lg.png │ │ ├── poster-sm.png │ │ ├── radarr_128.png │ │ ├── radarr_48.png │ │ ├── screenshots │ │ │ ├── home.png │ │ │ ├── light.png │ │ │ ├── mobile │ │ │ │ ├── home.png │ │ │ │ ├── light.png │ │ │ │ ├── series.png │ │ │ │ └── settings.png │ │ │ ├── series.png │ │ │ └── settings.png │ │ ├── sonarr_128.png │ │ └── sonarr_48.png │ ├── chunk-BTAGKXKY.js │ ├── chunk-C7R7W6XM.js │ ├── chunk-JAQZWSEL.js │ ├── chunk-LS7CSANC.js │ ├── chunk-NE4LPQX3.js │ ├── chunk-NVPX7OER.js │ ├── chunk-S6KXSEER.js │ ├── chunk-SN7ZTHVN.js │ ├── chunk-YAAHTPAX.js │ ├── index.html │ ├── main-CVSIWTUN.js │ ├── media │ │ └── poster-sm-XZCQRTNE.png │ ├── polyfills-TECF5RVS.js │ ├── routes-FGWRTABX.js │ ├── routes-JTB4G2ZP.js │ ├── routes-N2PS7GAA.js │ ├── routes-WCKUDEJE.js │ ├── routes-ZQ3YJMO2.js │ └── styles-G65SL4XA.css └── prerendered-routes.json ├── frontend ├── .editorconfig ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode │ ├── launch.json │ └── settings.json ├── README.md ├── angular.json ├── contract │ └── swagger.yaml ├── jest.config.ts ├── ng-openapi-gen.json ├── package-lock.json ├── package.json ├── src │ ├── app │ │ ├── app.component.html │ │ ├── app.component.scss │ │ ├── app.component.ts │ │ ├── app.config.ts │ │ ├── app.routes.ts │ │ ├── helpers │ │ │ ├── duration-pipe.spec.ts │ │ │ ├── duration-pipe.ts │ │ │ └── scroll-near-end-directive.ts │ │ ├── logs │ │ │ ├── logs.component.html │ │ │ ├── logs.component.scss │ │ │ ├── logs.component.ts │ │ │ └── routes.ts │ │ ├── media │ │ │ ├── add-filter-dialog │ │ │ │ ├── add-filter-dialog.component.html │ │ │ │ ├── add-filter-dialog.component.scss │ │ │ │ └── add-filter-dialog.component.ts │ │ │ ├── media-details │ │ │ │ ├── files │ │ │ │ │ ├── files.component.html │ │ │ │ │ ├── files.component.scss │ │ │ │ │ └── files.component.ts │ │ │ │ ├── media-details.component.html │ │ │ │ ├── media-details.component.scss │ │ │ │ ├── media-details.component.ts │ │ │ │ └── routes.ts │ │ │ ├── media.component.html │ │ │ ├── media.component.scss │ │ │ ├── media.component.ts │ │ │ ├── pipes │ │ │ │ └── display-title.pipe.ts │ │ │ └── routes.ts │ │ ├── models │ │ │ ├── connection.ts │ │ │ ├── customfilter.ts │ │ │ ├── logs.ts │ │ │ ├── media.ts │ │ │ ├── settings.ts │ │ │ └── tasks.ts │ │ ├── nav │ │ │ ├── sidenav │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── sidenav.component.spec.ts.snap │ │ │ │ ├── sidenav.component.html │ │ │ │ ├── sidenav.component.scss │ │ │ │ ├── sidenav.component.spec.ts │ │ │ │ └── sidenav.component.ts │ │ │ └── topnav │ │ │ │ ├── topnav.component.html │ │ │ │ ├── topnav.component.scss │ │ │ │ └── topnav.component.ts │ │ ├── services │ │ │ ├── customfilter.service.ts │ │ │ ├── logs.service.spec.ts │ │ │ ├── logs.service.ts │ │ │ ├── media.service.ts │ │ │ ├── settings.service.ts │ │ │ ├── tasks.service.ts │ │ │ └── websocket.service.ts │ │ ├── settings │ │ │ ├── __snapshots__ │ │ │ │ └── settings.component.spec.ts.snap │ │ │ ├── about │ │ │ │ ├── about.component.html │ │ │ │ ├── about.component.scss │ │ │ │ └── about.component.ts │ │ │ ├── connections │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── connections.component.spec.ts.snap │ │ │ │ ├── add-connection │ │ │ │ │ ├── add-connection.component.html │ │ │ │ │ ├── add-connection.component.scss │ │ │ │ │ └── add-connection.component.ts │ │ │ │ ├── connections.component.html │ │ │ │ ├── connections.component.spec.ts │ │ │ │ ├── connections.component.ts │ │ │ │ ├── edit-connection │ │ │ │ │ ├── edit-connection.component.html │ │ │ │ │ ├── edit-connection.component.scss │ │ │ │ │ └── edit-connection.component.ts │ │ │ │ └── show-connections │ │ │ │ │ ├── show-connections.component.html │ │ │ │ │ ├── show-connections.component.scss │ │ │ │ │ └── show-connections.component.ts │ │ │ ├── routes.ts │ │ │ ├── settings.component.html │ │ │ ├── settings.component.scss │ │ │ ├── settings.component.spec.ts │ │ │ ├── settings.component.ts │ │ │ └── trailer │ │ │ │ ├── trailer.component.html │ │ │ │ ├── trailer.component.scss │ │ │ │ └── trailer.component.ts │ │ ├── shared │ │ │ └── load-indicator │ │ │ │ ├── __snapshots__ │ │ │ │ └── load-indicator.component.spec.ts.snap │ │ │ │ ├── index.ts │ │ │ │ ├── load-indicator.component.spec.ts │ │ │ │ └── load-indicator.component.ts │ │ └── tasks │ │ │ ├── routes.ts │ │ │ ├── tasks.component.html │ │ │ ├── tasks.component.scss │ │ │ └── tasks.component.ts │ ├── assets │ │ ├── .gitkeep │ │ ├── IMDBlogo.png │ │ ├── TMDBlogo.png │ │ ├── TVDBlogo.png │ │ ├── icons │ │ │ ├── home.svg │ │ │ ├── movies.svg │ │ │ ├── series.svg │ │ │ └── settings.svg │ │ ├── logos │ │ │ ├── trailarr-192.ico │ │ │ ├── trailarr-192.png │ │ │ ├── trailarr-256.ico │ │ │ ├── trailarr-48.ico │ │ │ ├── trailarr-48.png │ │ │ ├── trailarr-512.png │ │ │ ├── trailarr-96.ico │ │ │ ├── trailarr-96.png │ │ │ ├── trailarr-full-512.png │ │ │ ├── trailarr-full-dark.svg │ │ │ ├── trailarr-full-light-512.png │ │ │ ├── trailarr-full-light.svg │ │ │ ├── trailarr-full.svg │ │ │ ├── trailarr-maskable-192.png │ │ │ ├── trailarr-maskable-512.png │ │ │ └── trailarr.svg │ │ ├── manifest.json │ │ ├── poster-lg.png │ │ ├── poster-sm.png │ │ ├── radarr_128.png │ │ ├── radarr_48.png │ │ ├── screenshots │ │ │ ├── home.png │ │ │ ├── light.png │ │ │ ├── mobile │ │ │ │ ├── home.png │ │ │ │ ├── light.png │ │ │ │ ├── series.png │ │ │ │ └── settings.png │ │ │ ├── series.png │ │ │ └── settings.png │ │ ├── sonarr_128.png │ │ └── sonarr_48.png │ ├── environment.ts │ ├── favicon.ico │ ├── index.html │ ├── jest-global-mocks.ts │ ├── jest-setup.ts │ ├── main.ts │ ├── routing.ts │ ├── styles │ │ └── styles.scss │ ├── util.spec.ts │ └── util.ts ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json ├── mkdocs.yml └── scripts ├── box_echo.sh ├── entrypoint.sh ├── healthcheck.py ├── install_ffmpeg.sh ├── start.sh └── update_ytdlp.sh /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/python:1-3.13-bullseye 2 | # Install the xz-utils package 3 | RUN apt-get update && apt-get install -y curl xz-utils tzdata \ 4 | git gnupg2 pciutils pinentry-curses \ 5 | && apt-get clean \ 6 | && rm -rf /var/lib/apt/lists/* 7 | 8 | # Install Node.js 9 | RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ 10 | && apt-get install -y nodejs \ 11 | && apt-get clean \ 12 | && rm -rf /var/lib/apt/lists/* 13 | 14 | # Install specific version of npm 15 | RUN npm install -g npm@11.3.0 16 | 17 | # Install specific version of Angular CLI 18 | RUN npm install -g @angular/cli@19.2.10 19 | 20 | # Set the working directory 21 | WORKDIR /app 22 | 23 | # Install Python Packages 24 | COPY dev-requirements.txt . 25 | RUN python -m pip install --no-cache-dir --disable-pip-version-check \ 26 | --upgrade -r /app/dev-requirements.txt 27 | 28 | # Download and extract the custom FFmpeg build from yt-dlp 29 | RUN curl -L -o /tmp/ffmpeg.tar.xz "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz" \ 30 | && mkdir /tmp/ffmpeg \ 31 | && tar -xf /tmp/ffmpeg.tar.xz -C /tmp/ffmpeg --strip-components=1 \ 32 | && mv /tmp/ffmpeg/bin/* /usr/local/bin/ \ 33 | && rm -rf /tmp/ffmpeg.tar.xz /tmp/ffmpeg 34 | 35 | # Set environment variables 36 | ENV PYTHONDONTWRITEBYTECODE=1 \ 37 | PYTHONUNBUFFERED=1 \ 38 | TZ="America/New_York" \ 39 | APP_NAME="Trailarr" \ 40 | APP_VERSION="0.4.0" \ 41 | NVIDIA_VISIBLE_DEVICES="all" \ 42 | NVIDIA_DRIVER_CAPABILITIES="all" 43 | 44 | # Set the python path 45 | ENV PYTHONPATH "${PYTHONPATH}:/app/backend" 46 | 47 | # Copy startup script 48 | COPY dev-start.sh /usr/local/bin/startup.sh 49 | RUN chmod +x /usr/local/bin/startup.sh 50 | 51 | # Expose the port the app runs on 52 | EXPOSE 7888 53 | -------------------------------------------------------------------------------- /.devcontainer/dev-requirements.txt: -------------------------------------------------------------------------------- 1 | # DevContainer requirements 2 | # Docs 3 | mkdocs-material==9.6.12 4 | mkdocs-git-revision-date-localized-plugin==1.4.5 5 | mkdocs_github_changelog==0.1.0 6 | # mkdocs-swagger-ui-tag==0.6.11 7 | 8 | # Backend 9 | aiohttp==3.11.18 10 | aiofiles==24.1.0 11 | alembic==1.15.2 12 | apscheduler==3.11.0 13 | async-lru==2.0.5 14 | fastapi[standard]==0.115.12 15 | bcrypt==4.3.0 16 | pillow==11.2.1 17 | sqlmodel==0.0.24 18 | yt-dlp[default]==2025.4.30 19 | 20 | # Testing 21 | aioresponses==0.7.8 22 | hypothesis==6.130.11 23 | schemathesis==3.39.16 24 | pytest==8.3.5 25 | pytest-asyncio==0.26.0 26 | pytest-cov==6.1.1 -------------------------------------------------------------------------------- /.devcontainer/dev-start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Set TimeZone based on env variable 4 | echo "Setting TimeZone to $TZ" 5 | echo $TZ > /etc/timezone && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime 6 | 7 | # Create data folder for storing database and other config files 8 | mkdir -p /config/logs && chown -R vscode:vscode /config 9 | 10 | echo "Checking for GPU availability..." 11 | # Check for NVIDIA GPU 12 | export NVIDIA_GPU_AVAILABLE="false" 13 | if command -v nvidia-smi &> /dev/null; then 14 | if nvidia-smi > /dev/null 2>&1; then 15 | echo "NVIDIA GPU is available." 16 | export NVIDIA_GPU_AVAILABLE="true" 17 | else 18 | echo "NVIDIA GPU is not available." 19 | fi 20 | else 21 | echo "nvidia-smi command not found." 22 | fi 23 | 24 | # Check if /dev/dri exists 25 | export QSV_GPU_AVAILABLE="false" 26 | if [ -d /dev/dri ]; then 27 | # Check for Intel GPU 28 | if ls /dev/dri | grep -q "renderD"; then 29 | # Intel QSV might be available. Further check for Intel-specific devices 30 | if lspci | grep -iE 'Display|VGA' | grep -i 'Intel'; then 31 | export QSV_GPU_AVAILABLE="true" 32 | echo "Intel GPU detected. Intel QSV is likely available." 33 | else 34 | echo "No Intel GPU detected. Intel QSV is not available." 35 | fi 36 | else 37 | echo "Intel QSV not detected. No renderD devices found in /dev/dri." 38 | fi 39 | else 40 | echo "Intel QSV is not available. /dev/dri does not exist." 41 | fi 42 | 43 | # Check the version of yt-dlp and store it in a global environment variable 44 | YTDLP_VERSION=$(yt-dlp --version) 45 | export YTDLP_VERSION 46 | 47 | # Run Alembic migrations 48 | echo "Running Alembic migrations" 49 | cd backend 50 | alembic upgrade head && echo "Alembic migrations ran successfully" 51 | 52 | # Install Angular dependencies 53 | echo "Installing Angular dependencies" 54 | cd ../frontend && npm install 55 | 56 | # Start Angular application 57 | # echo "Building Angular application" 58 | # cd /app/frontend && nohup ng serve & 59 | 60 | # Start FastAPI application 61 | # echo "Starting FastAPI application" 62 | # cd /app 63 | # exec gunicorn --bind 0.0.0.0:7888 -k uvicorn.workers.UvicornWorker backend.main:trailarr_api 64 | 65 | echo "Dev container started successfully!" -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/python 3 | { 4 | "name": "Trailarr", 5 | 6 | "build": { 7 | "dockerfile": "Dockerfile" 8 | }, 9 | "runArgs": [ 10 | // Uncomment to use NVIDIA runtime for GPU support 11 | // "--runtime=nvidia", 12 | "-p=7888:7888", 13 | "-p=7887:8000" 14 | ], 15 | 16 | // Mount appdata folder for persistent storage 17 | // Update these paths to match your local setup 18 | "mounts": [ 19 | // Mount appdata folder for persistent storage 20 | "source=/var/appdata/trailarr-dev,target=/config,type=bind,consistency=cached", 21 | // Mount media folder for app to detect the media library 22 | "source=/media/all/Media,target=/media,type=bind,consistency=cached", 23 | // Mount local GPG keys for signing 24 | "source=${localEnv:HOME}${localEnv:USERPROFILE}/.gnupg,target=/home/vscode/.gnupg,type=bind,consistency=cached" 25 | ], 26 | 27 | // Set workspace folder to /app so that the project is in the right place 28 | "workspaceMount": "source=${localWorkspaceFolder},target=/app,type=bind", 29 | "workspaceFolder": "/app", 30 | 31 | 32 | "containerEnv": { 33 | "PYTHONPATH": "${containerWorkspaceFolder}/backend", 34 | "LOG_LEVEL": "Info", 35 | "TESTING": "False", 36 | "APP_PORT": "7888", 37 | "APP_DATA_DIR": "/config", 38 | "NEW_DOWNLOAD_METHOD": "True" 39 | }, 40 | 41 | "customizations": { 42 | "vscode": { 43 | "extensions": [ 44 | "Angular.ng-template", 45 | "ms-python.python", 46 | "ms-python.vscode-pylance", 47 | "ms-python.debugpy", 48 | "GitHub.copilot", 49 | "ms-python.black-formatter", 50 | "ms-python.flake8", 51 | "Gruntfuggly.todo-tree", 52 | "MohammadBaqer.better-folding", 53 | "aaron-bond.better-comments", 54 | "usernamehw.errorlens", 55 | "KevinRose.vsc-python-indent", 56 | "qwtel.sqlite-viewer", 57 | "oderwat.indent-rainbow", 58 | "redhat.vscode-yaml", 59 | "GitHub.copilot-chat", 60 | "esbenp.prettier-vscode" 61 | ] 62 | } 63 | }, 64 | 65 | // Use 'postCreateCommand' to run commands after the container is created. 66 | "postCreateCommand": "sudo startup.sh" 67 | 68 | // Run container as a non-root user. 'vscode' is the default user. To run as root, set this to 'root'. 69 | // "remoteUser": "vscode" 70 | } 71 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | **/.venv 3 | **/.classpath 4 | **/.dockerignore 5 | # **/.env 6 | **/.git 7 | **/.gitignore 8 | **/.project 9 | **/.settings 10 | **/.toolstarget 11 | **/.vs 12 | **/.vscode 13 | **/*.*proj.user 14 | **/*.dbmdl 15 | **/*.jfm 16 | **/bin 17 | **/charts 18 | **/docker-compose* 19 | **/compose* 20 | **/Dockerfile* 21 | **/node_modules 22 | **/npm-debug.log 23 | **/obj 24 | **/secrets.dev.yaml 25 | **/values.dev.yaml 26 | LICENSE 27 | README.md 28 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @nandyalu -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[Bug]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | 13 | **Steps To Reproduce** 14 | 19 | 20 | **Actual behavior** 21 | 22 | 23 | **Expected behavior** 24 | 25 | 26 | **Screenshots** 27 | 28 | 29 | **App Information (please complete the following information):** 30 | - Base OS: [e.g. Ubuntu] 31 | - Architecture: (run `dpkg --print-architecture` to find out). [e.g. arm64, amd64] 32 | - Version [e.g. 22.04] 33 | - Browser (if related to webpage) [e.g. chrome 127.0.6533.89 (Official Build) (64-bit)] 34 | 35 | **Additional context** 36 | 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Request]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | 13 | **Describe the solution you'd like** 14 | 15 | 16 | **Describe alternatives you've considered** 17 | 18 | 19 | **Additional context** 20 | 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question-help-wanted.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question/Help wanted 3 | about: To ask a question or get help with something 4 | title: "[Question]" 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | - [ ] I already checked existing Documentation to see if this is covered 11 | - [ ] I already checked existing Issues to see if this is reported 12 | 13 | **Describe your question** 14 | 15 | 16 | **Additional context** 17 | 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for more information: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | # https://containers.dev/guide/dependabot 6 | 7 | version: 2 8 | updates: 9 | # Maintain dependencies for pip - devcontainer 10 | - package-ecosystem: "pip" 11 | directory: "/.devcontainer" 12 | schedule: 13 | interval: daily 14 | target-branch: "dev" 15 | open-pull-requests-limit: 5 # Limit the number of open PRs 16 | 17 | # Maintain dependencies for pip - backend 18 | # - package-ecosystem: "pip" 19 | # directory: "/backend" 20 | # schedule: 21 | # interval: weekly 22 | # target-branch: "main" 23 | # open-pull-requests-limit: 3 # Limit the number of open PRs 24 | 25 | # Maintain dependencies for npm - frontend (Angular and rxjs only) 26 | # - package-ecosystem: "npm" 27 | # directory: "/frontend" 28 | # schedule: 29 | # interval: weekly 30 | # target-branch: "dev" 31 | # open-pull-requests-limit: 3 # Limit the number of open PRs 32 | # allow: 33 | # - dependency-name: "@angular/*" 34 | # - dependency-name: "rxjs" 35 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | # .github/release.yml 2 | 3 | changelog: 4 | exclude: 5 | labels: 6 | - ignore-for-release 7 | authors: 8 | - octocat 9 | categories: 10 | - title: Breaking Changes 🛠 11 | labels: 12 | - Semver-Major 13 | - breaking-change 14 | - title: Exciting New Features 🎉 15 | labels: 16 | - Semver-Minor 17 | - enhancement 18 | - title: Other Changes 19 | labels: 20 | - "*" 21 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | # Ignore changes to assets, .devcontainer, .github, or .vscode files 7 | paths-ignore: 8 | - '**/assets/**' 9 | - '**/.vscode/**' 10 | - '**/.github/**' 11 | - '**/.devcontainer/**' 12 | - '**/*.md' 13 | - '**/docs/**' 14 | - '**/frontend/**' 15 | - '**/mkdocs.yml' 16 | pull_request: 17 | branches: [ "main" ] 18 | paths-ignore: 19 | - '**/assets/**' 20 | - '**/.vscode/**' 21 | - '**/.github/**' 22 | - '**/.devcontainer/**' 23 | - '**/*.md' 24 | - '**/docs/**' 25 | - '**/frontend/**' 26 | - '**/mkdocs.yml' 27 | 28 | jobs: 29 | 30 | build: 31 | name: Docker Build 32 | runs-on: ubuntu-latest 33 | env: 34 | VER: ${{ github.sha }}-test 35 | # Explicitly specify permissions 36 | permissions: 37 | contents: read 38 | actions: write 39 | 40 | # steps: 41 | # Checkout the repository 42 | # - name: Checkout repository 43 | # uses: actions/checkout@v4 44 | 45 | # # Build the Docker image 46 | # - name: Build the Docker image 47 | # run: docker build . --file Dockerfile --tag trailarr:${{env.VER}} --build-arg APP_VERSION=${{ env.VER }} 48 | 49 | steps: 50 | - name: Checkout repository 51 | uses: actions/checkout@v4 52 | 53 | - name: Setup Buildx 54 | uses: docker/setup-buildx-action@v3 55 | 56 | - name: Ensure cache directory exists 57 | run: mkdir -p /tmp/.buildx-cache 58 | 59 | - name: Cache Docker layers 60 | uses: actions/cache@v3 61 | with: 62 | # Cache key based on requirements.txt and Dockerfile 63 | path: /tmp/.buildx-cache 64 | key: ${{ runner.os }}-docker-${{ hashFiles('requirements.txt', 'Dockerfile') }} 65 | restore-keys: | 66 | ${{ runner.os }}-docker- 67 | 68 | - name: Build and push Docker image 69 | uses: docker/build-push-action@v6 70 | with: 71 | push: false 72 | cache-from: type=gha 73 | cache-to: type=gha,mode=max 74 | -------------------------------------------------------------------------------- /.github/workflows/docs-build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy Docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - docs/** 8 | - mkdocs.yml 9 | permissions: 10 | contents: write 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Configure Git Credentials 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 # Needed for mkdocs-git-revision-date-localized-plugin 19 | # github-token: "${{ secrets.GITHUB_TOKEN }}" 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | - run: | 23 | git config user.name github-actions[bot] 24 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 25 | - uses: actions/setup-python@v5 26 | with: 27 | python-version: 3.x 28 | # - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 29 | # - uses: actions/cache@v4 30 | # with: 31 | # key: mkdocs-material-${{ env.cache_id }} 32 | # path: .cache 33 | # restore-keys: | 34 | # mkdocs-material- 35 | - name: Install Dependencies 36 | run: pip install mkdocs-material mkdocs-git-revision-date-localized-plugin mkdocs_github_changelog 37 | - name: Build and Deploy 38 | run: mkdocs gh-deploy --force 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/update-dockerhub-description.yml: -------------------------------------------------------------------------------- 1 | name: Update DockerHub Description 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - README.md 9 | - .github/workflows/dockerhub-description.yml 10 | 11 | jobs: 12 | dockerHubDescription: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Update Docker Hub Description 21 | uses: peter-evans/dockerhub-description@v4 22 | with: 23 | username: ${{ secrets.DOCKERHUB_USERNAME }} 24 | password: ${{ secrets.DOCKERHUB_TOKEN }} 25 | enable-url-completion: true 26 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: FastAPI", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "cwd": "${workspaceFolder}/backend", 12 | "module": "uvicorn", 13 | "args": [ 14 | "main:trailarr_api", 15 | "--reload", 16 | "--port=7888" 17 | ], 18 | "jinja": true, 19 | "justMyCode": true 20 | }, 21 | { 22 | "name": "Docker: Python - Fastapi", 23 | "type": "docker", 24 | "request": "launch", 25 | "preLaunchTask": "docker-run: debug", 26 | "python": { 27 | "pathMappings": [ 28 | { 29 | "localRoot": "${workspaceFolder}", 30 | "remoteRoot": "/app" 31 | } 32 | ], 33 | "projectType": "fastapi" 34 | } 35 | }, 36 | { 37 | "name": "ng serve", 38 | "type": "msedge", 39 | "request": "launch", 40 | "preLaunchTask": "npm: start", 41 | "cwd": "${workspaceFolder}/frontend", // Added this line 42 | "url": "http://localhost:4200/", 43 | "webRoot": "${workspaceFolder}/frontend/" 44 | }, 45 | { 46 | "name": "ng test", 47 | "type": "chrome", 48 | "request": "launch", 49 | "preLaunchTask": "npm: test", 50 | "cwd": "${workspaceFolder}/frontend", // Added this line 51 | "url": "http://localhost:9876/debug.html" 52 | }, 53 | { 54 | "type": "node", 55 | "request": "launch", 56 | "name": "Jest Current File", 57 | "program": "${workspaceFolder}/frontend/node_modules/.bin/jest", 58 | "args": [ 59 | "--updateSnapshot", 60 | "--runInBand", 61 | "--runTestsByPath", 62 | "${relativeFile}" 63 | ], 64 | "console": "integratedTerminal", 65 | "internalConsoleOptions": "neverOpen", 66 | "disableOptimisticBPs": true, 67 | "windows": { 68 | "program": "${workspaceFolder}/frontend/node_modules/jest/bin/jest" 69 | } 70 | } 71 | ] 72 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1 - Python dependencies 2 | FROM python:3.12-slim AS python-deps 3 | 4 | # PYTHONDONTWRITEBYTECODE=1 -> Keeps Python from generating .pyc files in the container 5 | # PYTHONUNBUFFERED=1 -> Turns off buffering for easier container logging 6 | ENV PYTHONDONTWRITEBYTECODE=1 \ 7 | PYTHONUNBUFFERED=1 8 | 9 | # Set the working directory 10 | WORKDIR /app 11 | 12 | # Install pip requirements 13 | COPY ./backend/requirements.txt . 14 | RUN python -m pip install --no-cache-dir --disable-pip-version-check \ 15 | --upgrade -r /app/requirements.txt 16 | 17 | # Install ffmpeg using install_ffmpeg.sh script 18 | COPY ./scripts/install_ffmpeg.sh /tmp/install_ffmpeg.sh 19 | RUN chmod +x /tmp/install_ffmpeg.sh && \ 20 | /tmp/install_ffmpeg.sh 21 | 22 | # Stage 2 - Final image 23 | FROM python:3.12-slim 24 | 25 | # ARG APP_VERSION, will be set during build by github actions 26 | ARG APP_VERSION=0.0.0-dev 27 | 28 | # Set environment variables 29 | ENV PYTHONDONTWRITEBYTECODE=1 \ 30 | PYTHONUNBUFFERED=1 \ 31 | TZ="America/New_York" \ 32 | APP_NAME="Trailarr" \ 33 | APP_PORT=7889 \ 34 | APP_DATA_DIR="/config" \ 35 | PUID=1000 \ 36 | PGID=1000 \ 37 | APP_VERSION=${APP_VERSION} \ 38 | NVIDIA_VISIBLE_DEVICES="all" \ 39 | NVIDIA_DRIVER_CAPABILITIES="all" 40 | 41 | # Install tzdata, pciutils and set timezone 42 | RUN apt-get update && apt-get install -y tzdata pciutils && \ 43 | ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime && \ 44 | dpkg-reconfigure -f noninteractive tzdata && \ 45 | rm -rf /var/lib/apt/lists/* 46 | 47 | # Set the working directory 48 | WORKDIR /app 49 | 50 | # Copy the assets folder 51 | COPY ./assets /app/assets 52 | 53 | # Copy the backend 54 | COPY ./backend /app/backend 55 | 56 | # Copy the frontend built files 57 | COPY ./frontend-build /app/frontend-build 58 | 59 | # Copy the installed Python dependencies and ffmpeg 60 | COPY --from=python-deps /usr/local/ /usr/local/ 61 | 62 | # Set the python path 63 | ENV PYTHONPATH=/app/backend 64 | 65 | # Copy the scripts folder, and make all scripts executable 66 | COPY ./scripts /app/scripts 67 | RUN chmod +x /app/scripts/*.sh 68 | 69 | # Expose the port the app runs on 70 | EXPOSE ${APP_PORT} 71 | 72 | # Set permissions for appuser on /app directory 73 | RUN chmod -R 750 /app 74 | 75 | # Define a healthcheck command 76 | HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \ 77 | CMD python /app/scripts/healthcheck.py ${APP_PORT} 78 | 79 | # Run entrypoint script to create directories, set permissions and timezone \ 80 | # and start the application as appuser 81 | ENTRYPOINT ["/app/scripts/entrypoint.sh"] 82 | -------------------------------------------------------------------------------- /assets/images/trailarr-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-128.png -------------------------------------------------------------------------------- /assets/images/trailarr-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-192.png -------------------------------------------------------------------------------- /assets/images/trailarr-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-256.png -------------------------------------------------------------------------------- /assets/images/trailarr-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-48.png -------------------------------------------------------------------------------- /assets/images/trailarr-512-lg.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-512-lg.ico -------------------------------------------------------------------------------- /assets/images/trailarr-512.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-512.ico -------------------------------------------------------------------------------- /assets/images/trailarr-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-512.png -------------------------------------------------------------------------------- /assets/images/trailarr-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-64.png -------------------------------------------------------------------------------- /assets/images/trailarr-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-96.png -------------------------------------------------------------------------------- /assets/images/trailarr-full-512-lg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-full-512-lg.png -------------------------------------------------------------------------------- /assets/images/trailarr-full-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-full-512.png -------------------------------------------------------------------------------- /assets/images/trailarr-full-light-512-lg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-full-light-512-lg.png -------------------------------------------------------------------------------- /assets/images/trailarr-full-light-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-full-light-512.png -------------------------------------------------------------------------------- /assets/images/trailarr-full-primary-512-lg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-full-primary-512-lg.png -------------------------------------------------------------------------------- /assets/images/trailarr-full-primary-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-full-primary-512.png -------------------------------------------------------------------------------- /assets/images/trailarr-maskable-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-maskable-192.png -------------------------------------------------------------------------------- /assets/images/trailarr-maskable-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-maskable-512.png -------------------------------------------------------------------------------- /assets/images/trailarr-maskable-lg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/assets/images/trailarr-maskable-lg.png -------------------------------------------------------------------------------- /assets/images/trailarr.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = . 4 | omit = */__pycache__/*, */__init__.py, backend/tests/*, frontend/tests/*, 5 | -------------------------------------------------------------------------------- /backend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/__init__.py -------------------------------------------------------------------------------- /backend/alembic/README.md: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | 3 | For future releases/migrations if any changes were made to database models, run the following in devcontainer: 4 | 5 | 1. Rename the database file `/config/trailarr.db` to `/config/trailarr_old.db`. 6 | >>>> mv /config/trailarr.db /config/trailarr_old.db 7 | 8 | 2. Run alembic upgrade head command to create a new database file `/config/trailarr.db` with all existing migrations. 9 | >>>> cd /app/backend 10 | >>>> alembic upgrade head 11 | 12 | 3. Run alembic create migration command to create a new migration. 13 | >>>> cd /app/backend 14 | >>>> alembic revision --autogenerate -m "With an appropriate message" 15 | 16 | 4. Delte the `/config/trailarr.db` file that was created in step 2. 17 | >>>> rm /config/trailarr.db 18 | 19 | 5. Restore the database file `/config/trailarr_old.db` to `/config/trailarr.db`. 20 | >>>> mv /config/trailarr_old.db /config/trailarr.db 21 | 22 | 6. Now run `/app/scripts/start.sh` to run migrations on existing database. Ensure that the database migrations are successful. Test the application to ensure that the changes are working as expected. 23 | -------------------------------------------------------------------------------- /backend/alembic/env.py: -------------------------------------------------------------------------------- 1 | # from logging.config import fileConfig 2 | 3 | from sqlalchemy import engine_from_config 4 | from sqlalchemy import pool 5 | from sqlmodel import SQLModel 6 | 7 | from alembic import context 8 | 9 | import core.base.database.utils.init_db # noqa: F401 10 | from config.settings import app_settings 11 | import app_logger # noqa: F401 12 | 13 | # this is the Alembic Config object, which provides 14 | # access to the values within the .ini file in use. 15 | config = context.config 16 | 17 | # Interpret the config file for Python logging. 18 | # This line sets up loggers basically. 19 | # if config.config_file_name is not None: 20 | # fileConfig(config.config_file_name) 21 | # ## Removed default logging configuration and import app_logger instead 22 | 23 | # add your model's MetaData object here 24 | # for 'autogenerate' support 25 | # from myapp import mymodel 26 | # target_metadata = mymodel.Base.metadata 27 | target_metadata = SQLModel.metadata 28 | 29 | # other values from the config, defined by the needs of env.py, 30 | # can be acquired: 31 | # my_important_option = config.get_main_option("my_important_option") 32 | # ... etc. 33 | config.set_main_option("sqlalchemy.url", app_settings.database_url) 34 | 35 | 36 | def run_migrations_offline() -> None: 37 | """Run migrations in 'offline' mode. 38 | 39 | This configures the context with just a URL 40 | and not an Engine, though an Engine is acceptable 41 | here as well. By skipping the Engine creation 42 | we don't even need a DBAPI to be available. 43 | 44 | Calls to context.execute() here emit the given string to the 45 | script output. 46 | 47 | """ 48 | url = config.get_main_option("sqlalchemy.url") 49 | context.configure( 50 | url=url, 51 | target_metadata=target_metadata, 52 | render_as_batch=True, 53 | literal_binds=True, 54 | dialect_opts={"paramstyle": "named"}, 55 | ) 56 | 57 | with context.begin_transaction(): 58 | context.run_migrations() 59 | 60 | 61 | def run_migrations_online() -> None: 62 | """Run migrations in 'online' mode. 63 | 64 | In this scenario we need to create an Engine 65 | and associate a connection with the context. 66 | 67 | """ 68 | connectable = engine_from_config( 69 | config.get_section(config.config_ini_section, {}), 70 | prefix="sqlalchemy.", 71 | poolclass=pool.NullPool, 72 | ) 73 | 74 | with connectable.connect() as connection: 75 | context.configure( 76 | connection=connection, target_metadata=target_metadata, render_as_batch=True 77 | ) 78 | 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | 82 | 83 | if context.is_offline_mode(): 84 | run_migrations_offline() 85 | else: 86 | run_migrations_online() 87 | -------------------------------------------------------------------------------- /backend/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | import sqlmodel 13 | import sqlmodel.sql.sqltypes 14 | from app_logger import ModuleLogger 15 | ${imports if imports else ""} 16 | 17 | # revision identifiers, used by Alembic. 18 | revision: str = ${repr(up_revision)} 19 | down_revision: Union[str, None] = ${repr(down_revision)} 20 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 21 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 22 | 23 | logging = ModuleLogger("AlembicMigrations") 24 | 25 | 26 | def upgrade() -> None: 27 | ${upgrades if upgrades else "pass"} 28 | 29 | 30 | def downgrade() -> None: 31 | ${downgrades if downgrades else "pass"} 32 | -------------------------------------------------------------------------------- /backend/alembic/versions/20240917_1327-1cc1dd5dbe9f_add_media_status.py: -------------------------------------------------------------------------------- 1 | """Add Media Status 2 | 3 | Revision ID: 1cc1dd5dbe9f 4 | Revises: 34f937f1d7b9 5 | Create Date: 2024-09-17 13:27:49.217863 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | from app_logger import ModuleLogger 14 | 15 | 16 | # revision identifiers, used by Alembic. 17 | revision: str = "1cc1dd5dbe9f" 18 | down_revision: Union[str, None] = "34f937f1d7b9" 19 | branch_labels: Union[str, Sequence[str], None] = None 20 | depends_on: Union[str, Sequence[str], None] = None 21 | 22 | logging = ModuleLogger("AlembicMigrations") 23 | 24 | 25 | def upgrade() -> None: 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | with op.batch_alter_table("media", schema=None) as batch_op: 28 | batch_op.add_column( 29 | sa.Column( 30 | "status", 31 | sa.Enum( 32 | "DOWNLOADED", 33 | "DOWNLOADING", 34 | "MISSING", 35 | "MONITORED", 36 | name="monitorstatus", 37 | ), 38 | server_default="MISSING", 39 | nullable=False, 40 | ) 41 | ) 42 | 43 | # ### end Alembic commands ### 44 | 45 | 46 | def downgrade() -> None: 47 | # ### commands auto generated by Alembic - please adjust! ### 48 | with op.batch_alter_table("media", schema=None) as batch_op: 49 | batch_op.drop_column("status") 50 | 51 | # ### end Alembic commands ### 52 | -------------------------------------------------------------------------------- /backend/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/api/__init__.py -------------------------------------------------------------------------------- /backend/api/v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/api/v1/__init__.py -------------------------------------------------------------------------------- /backend/api/v1/customfilters.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from core.base.database.manager import customfilter 4 | from core.base.database.models.customfilter import ( 5 | CustomFilterCreate, 6 | CustomFilterRead, 7 | ) 8 | 9 | customfilters_router = APIRouter( 10 | prefix="/customfilters", tags=["Custom Filters"] 11 | ) 12 | 13 | 14 | @customfilters_router.post("/") 15 | async def create_or_update_customfilter( 16 | view_filter: CustomFilterCreate, 17 | ) -> CustomFilterRead: 18 | return customfilter.create_customfilter(view_filter) 19 | 20 | 21 | @customfilters_router.put("/{id}") 22 | async def update_customfilter( 23 | id: int, 24 | view_filter: CustomFilterCreate, 25 | ) -> CustomFilterRead: 26 | return customfilter.update_customfilter(id, view_filter) 27 | 28 | 29 | @customfilters_router.delete("/{id}") 30 | async def delete_customfilter(id: int) -> bool: 31 | return customfilter.delete_customfilter(id) 32 | 33 | 34 | @customfilters_router.get("/home") 35 | async def get_home_customfilters() -> list[CustomFilterRead]: 36 | return customfilter.get_home_customfilters() 37 | 38 | 39 | @customfilters_router.get("/movie") 40 | async def get_movie_customfilters() -> list[CustomFilterRead]: 41 | return customfilter.get_movie_customfilters() 42 | 43 | 44 | @customfilters_router.get("/series") 45 | async def get_series_customfilters() -> list[CustomFilterRead]: 46 | return customfilter.get_series_customfilters() 47 | -------------------------------------------------------------------------------- /backend/api/v1/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | # THESE MODELS ARE ONLY FOR API RESPONSES 4 | 5 | 6 | class BatchUpdate(BaseModel): 7 | media_ids: list[int] 8 | action: str 9 | profile_id: int | None = None 10 | 11 | 12 | class ErrorResponse(BaseModel): 13 | message: str 14 | 15 | 16 | class Log(BaseModel): 17 | datetime: str 18 | level: str 19 | filename: str 20 | lineno: int 21 | module: str 22 | message: str 23 | raw_log: str 24 | 25 | 26 | class SearchMedia(BaseModel): 27 | id: int 28 | title: str 29 | year: int 30 | youtube_trailer_id: str 31 | imdb_id: str 32 | txdb_id: str 33 | is_movie: bool 34 | poster_path: str | None 35 | 36 | 37 | class Settings(BaseModel): 38 | api_key: str 39 | app_data_dir: str 40 | app_mode: str 41 | exclude_words: str 42 | version: str 43 | server_start_time: str 44 | timezone: str 45 | log_level: str 46 | monitor_enabled: bool 47 | monitor_interval: int 48 | trailer_folder_movie: bool 49 | trailer_folder_series: bool 50 | trailer_resolution: int 51 | trailer_file_name: str 52 | trailer_file_format: str 53 | trailer_always_search: bool 54 | trailer_search_query: str 55 | trailer_audio_format: str 56 | trailer_audio_volume_level: int 57 | trailer_video_format: str 58 | trailer_subtitles_enabled: bool 59 | trailer_subtitles_format: str 60 | trailer_subtitles_language: str 61 | trailer_embed_metadata: bool 62 | trailer_min_duration: int 63 | trailer_max_duration: int 64 | trailer_remove_sponsorblocks: bool 65 | trailer_web_optimized: bool 66 | update_available: bool 67 | wait_for_media: bool 68 | yt_cookies_path: str 69 | ytdlp_version: str 70 | trailer_remove_silence: bool 71 | nvidia_gpu_available: bool 72 | trailer_hardware_acceleration: bool 73 | new_download_method: bool 74 | update_ytdlp: bool 75 | url_base: str 76 | 77 | 78 | class UpdateSetting(BaseModel): 79 | key: str 80 | value: int | str | bool 81 | 82 | 83 | class UpdateLogin(BaseModel): 84 | current_password: str 85 | new_username: str | None 86 | new_password: str | None 87 | -------------------------------------------------------------------------------- /backend/api/v1/settings.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from api.v1.models import Settings, UpdateLogin, UpdateSetting 4 | from api.v1 import authentication 5 | from config.settings import app_settings 6 | from core.base.database.manager.general import ( 7 | GeneralDatabaseManager, 8 | ServerStats, 9 | ) 10 | 11 | settings_router = APIRouter(prefix="/settings", tags=["Settings"]) 12 | 13 | 14 | @settings_router.get("/") 15 | async def get_settings() -> Settings: 16 | return Settings(**app_settings.as_dict()) 17 | 18 | 19 | @settings_router.get("/stats") 20 | async def get_stats() -> ServerStats: 21 | return GeneralDatabaseManager().get_stats() 22 | 23 | 24 | @settings_router.put("/update") 25 | async def update_setting(update: UpdateSetting) -> str: 26 | if not update.key: 27 | return "Error updating setting: Key is required" 28 | if update.value is None or update.value == "": 29 | return "Error updating setting: Value is required" 30 | if not hasattr(app_settings, update.key): 31 | msg = "Error updating setting: Invalid key" 32 | msg += ( 33 | f" '{update.key}'! Valid values are" 34 | f" {app_settings.as_dict().keys()}" 35 | ) 36 | return msg 37 | setattr(app_settings, update.key, update.value) 38 | _new_value = getattr(app_settings, update.key, None) 39 | _name = update.key.replace("_", " ").title() 40 | return f"Setting {_name} updated to {_new_value}" 41 | 42 | 43 | @settings_router.put("/updatelogin") 44 | async def update_login(login: UpdateLogin) -> str: 45 | # Current username and password are required 46 | if not login.current_password: 47 | return "Error updating login: Current password is required!" 48 | 49 | # Verify the current password 50 | if not authentication.verify_password(login.current_password): 51 | return "Error updating login: Current password is incorrect!" 52 | 53 | # New username and password are optional, but at least one is required 54 | if login.new_username: 55 | # If only the new username is provided, set it 56 | if not login.new_password: 57 | return authentication.set_username(login.new_username) 58 | else: 59 | # If both are provided, set both 60 | authentication.set_username(login.new_username) 61 | authentication.set_password(login.new_password) 62 | return "Username and password updated successfully" 63 | # If only the new password is provided, set it 64 | if login.new_password: 65 | return authentication.set_password(login.new_password) 66 | # If neither is provided, return an error 67 | return "Error updating credentials: None were provided!" 68 | -------------------------------------------------------------------------------- /backend/api/v1/tasks.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from core.tasks import task_logging 4 | from core.tasks import schedules 5 | 6 | # from core.tasks.task_runner import TaskRunner 7 | 8 | 9 | tasks_router = APIRouter(prefix="/tasks", tags=["Tasks"]) 10 | 11 | 12 | @tasks_router.get("/schedules") 13 | async def get_scheduled_tasks() -> list[task_logging.TaskInfo]: 14 | return task_logging.get_all_tasks() 15 | 16 | 17 | @tasks_router.get("/queue") 18 | async def get_task_queue() -> list[task_logging.QueueInfo]: 19 | return task_logging.get_all_queue() 20 | 21 | 22 | @tasks_router.get("/run/{task_id}") 23 | async def run_task_now(task_id: str) -> str: 24 | msg = schedules.run_task_now(task_id) 25 | return msg 26 | -------------------------------------------------------------------------------- /backend/api/v1/websockets.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from fastapi import WebSocket 3 | 4 | 5 | class WSConnectionManager: 6 | """Connection manager for websockets to keep track of active connections \n 7 | ***Singleton Class*** 8 | """ 9 | 10 | _instance = None 11 | 12 | def __new__(cls) -> "WSConnectionManager": 13 | if cls._instance is None: 14 | cls._instance = super().__new__(cls) 15 | return cls._instance 16 | 17 | def __init__(self): 18 | self.active_connections: list[WebSocket] = [] 19 | 20 | async def connect(self, websocket: WebSocket): 21 | await websocket.accept() 22 | self.active_connections.append(websocket) 23 | 24 | def disconnect(self, websocket: WebSocket): 25 | self.active_connections.remove(websocket) 26 | 27 | async def send_personal_message(self, message: str, websocket: WebSocket): 28 | await websocket.send_text(message) 29 | 30 | async def broadcast(self, message: str, type: str = "Success") -> None: 31 | """Send a message to all connected clients. 32 | Args: 33 | message (str): The message to send. 34 | type (str, optional): The type of message. Defaults to "Success". 35 | Returns: 36 | None 37 | """ 38 | for connection in self.active_connections: 39 | await connection.send_json({"type": type, "message": message}) 40 | 41 | 42 | def broadcast(message: str, type: str = "Success") -> None: 43 | """Send a message to all connected clients. Non-Async function. 44 | Args: 45 | message (str): The message to send. 46 | type (str, optional): The type of message. Defaults to "Success". 47 | Returns: 48 | None 49 | """ 50 | 51 | def send_message() -> None: 52 | """Run the async task in a separate event loop.""" 53 | new_loop = asyncio.new_event_loop() 54 | asyncio.set_event_loop(new_loop) 55 | new_loop.run_until_complete(ws_manager.broadcast(message, type)) 56 | new_loop.close() 57 | return 58 | 59 | send_message() 60 | return 61 | 62 | 63 | ws_manager = WSConnectionManager() 64 | -------------------------------------------------------------------------------- /backend/app_logger.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import json 3 | import logging 4 | import logging.config 5 | import multiprocessing 6 | import pathlib 7 | import threading 8 | 9 | from config import app_logger_opts 10 | from config.settings import app_settings 11 | 12 | _is_logging_setup = False 13 | 14 | 15 | def handle_logs(q: multiprocessing.Queue): 16 | while True: 17 | record = q.get() 18 | # if record is None: 19 | # break 20 | logger = logging.getLogger(record.name) 21 | logger.handle(record) 22 | 23 | 24 | def stop_logging(queue: multiprocessing.Queue): 25 | # queue.put_nowait(None) # Did not work, raising an exception 26 | queue.close() 27 | 28 | 29 | def config_logging(): 30 | """Setup the logging configuration using the config file. 31 | This will setup the root logger configuration and start the queue handler listener. 32 | """ 33 | queue = multiprocessing.Queue(-1) 34 | parent_path = pathlib.Path(__file__).parent 35 | config_file = pathlib.Path(parent_path, "config", "logger_config.json") 36 | config = {} 37 | if config_file.exists(): 38 | with open(config_file) as f_in: 39 | config = json.load(f_in) 40 | config["handlers"]["file"][ 41 | "filename" 42 | ] = f"{app_settings.app_data_dir}/logs/trailarr.log" 43 | else: 44 | logging.debug(f"Logger config file not found: {config_file}") 45 | 46 | logging.config.dictConfig(config) 47 | app_logger_opts.set_logger_level(app_settings.log_level) 48 | logger_thread = threading.Thread(target=handle_logs, args=(queue,)) 49 | logger_thread.daemon = True 50 | logger_thread.start() 51 | atexit.register(stop_logging, queue) 52 | 53 | 54 | def get_logger(): 55 | return logging.getLogger("trailarr") # __name__ is a common choice 56 | 57 | 58 | TRACE_LEVEL = 5 59 | logging.addLevelName(TRACE_LEVEL, "TRACE") 60 | 61 | 62 | class ModuleLogger(logging.LoggerAdapter): 63 | """A custom logger adapter to add a prefix to log messages.""" 64 | 65 | def __init__(self, log_prefix: str): 66 | """Use this logger to add a prefix to log messages. \n 67 | Args: 68 | log_prefix (str): The prefix to add to log messages.""" 69 | self.log_prefix = log_prefix 70 | logger = logging.getLogger(__name__) 71 | super(ModuleLogger, self).__init__(logger, {}) 72 | 73 | def trace(self, message, *args, **kwargs): 74 | if self.isEnabledFor(TRACE_LEVEL): 75 | self._log(TRACE_LEVEL, message, args, **kwargs) 76 | 77 | def process(self, msg, kwargs): 78 | return "%s: %s" % (self.log_prefix, msg), kwargs 79 | 80 | 81 | if not _is_logging_setup: 82 | config_logging() 83 | _is_logging_setup = True 84 | logger = get_logger() 85 | -------------------------------------------------------------------------------- /backend/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/config/__init__.py -------------------------------------------------------------------------------- /backend/config/app_logger_opts.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def set_handler_level(handler_name, log_level: int): 5 | """Set the level for a specific handler.""" 6 | logger = logging.getLogger() 7 | for handler in logger.handlers: 8 | if handler.get_name() == handler_name: 9 | handler.setLevel(log_level) 10 | break 11 | return 12 | 13 | 14 | def set_logger_level(log_level: str) -> None: 15 | """Set the log level for the root logger.""" 16 | log_levels = { 17 | "DEBUG": logging.DEBUG, 18 | "INFO": logging.INFO, 19 | "WARNING": logging.WARNING, 20 | "ERROR": logging.ERROR, 21 | "CRITICAL": logging.CRITICAL, 22 | } 23 | # _log_level = "DEBUG" if app_settings.debug else "INFO" 24 | level = log_levels.get(log_level, logging.INFO) 25 | logging.getLogger().setLevel(level) 26 | set_handler_level("console", level) 27 | set_handler_level("file", level) 28 | logging.info(f"Log level set to '{log_level}'") 29 | return 30 | -------------------------------------------------------------------------------- /backend/config/logger_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "disable_existing_loggers": false, 4 | "formatters": { 5 | "simple": { 6 | "format": "%(levelname)s: %(message)s" 7 | }, 8 | "detailed": { 9 | "format": "%(asctime)s [%(levelname)s|%(module)s|L%(lineno)03d]: %(message)s", 10 | "datefmt": "%Y-%m-%dT%H:%M:%S%z" 11 | } 12 | }, 13 | "handlers": { 14 | "console": { 15 | "class": "logging.StreamHandler", 16 | "level": "INFO", 17 | "formatter": "detailed", 18 | "stream": "ext://sys.stdout" 19 | }, 20 | "stderr": { 21 | "class": "logging.StreamHandler", 22 | "level": "WARNING", 23 | "formatter": "simple", 24 | "stream": "ext://sys.stderr" 25 | }, 26 | "file": { 27 | "class": "logging.handlers.RotatingFileHandler", 28 | "level": "INFO", 29 | "formatter": "detailed", 30 | "filename": "/data/logs/trailarr.log", 31 | "maxBytes": 1000000, 32 | "backupCount": 10 33 | } 34 | }, 35 | "loggers": { 36 | "root": { 37 | "level": "INFO", 38 | "handlers": [ 39 | "console", "stderr", "file" 40 | ] 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /backend/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/core/__init__.py -------------------------------------------------------------------------------- /backend/core/base/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/core/base/__init__.py -------------------------------------------------------------------------------- /backend/core/base/arr_manager/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/core/base/arr_manager/__init__.py -------------------------------------------------------------------------------- /backend/core/base/database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/core/base/database/__init__.py -------------------------------------------------------------------------------- /backend/core/base/database/manager/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/core/base/database/manager/__init__.py -------------------------------------------------------------------------------- /backend/core/base/database/manager/customfilter/__init__.py: -------------------------------------------------------------------------------- 1 | from core.base.database.manager.customfilter.create import ( 2 | create_customfilter, 3 | ) 4 | from core.base.database.manager.customfilter.delete import delete_customfilter 5 | from core.base.database.manager.customfilter.read import ( 6 | get_home_customfilters, 7 | get_movie_customfilters, 8 | get_series_customfilters, 9 | ) 10 | from core.base.database.manager.customfilter.update import update_customfilter 11 | 12 | __ALL__ = [ 13 | create_customfilter, 14 | delete_customfilter, 15 | get_home_customfilters, 16 | get_movie_customfilters, 17 | get_series_customfilters, 18 | update_customfilter, 19 | ] 20 | -------------------------------------------------------------------------------- /backend/core/base/database/manager/customfilter/base.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence 2 | from core.base.database.models.customfilter import ( 3 | CustomFilter, 4 | CustomFilterRead, 5 | ) 6 | 7 | 8 | def convert_to_read_item(db_customfilter: CustomFilter) -> CustomFilterRead: 9 | """ 10 | Convert a CustomFilter database object to a CustomFilterRead object. 11 | Args: 12 | db_customfilter (CustomFilter): CustomFilter database object 13 | Returns: 14 | CustomFilterRead: CustomFilterRead object 15 | """ 16 | customfilter_read = CustomFilterRead.model_validate(db_customfilter) 17 | return customfilter_read 18 | 19 | 20 | def convert_to_read_list( 21 | db_customfilter_list: Sequence[CustomFilter], 22 | ) -> list[CustomFilterRead]: 23 | """ 24 | Convert a list of CustomFilter database objects to a list of \ 25 | CustomFilterRead objects. 26 | Args: 27 | db_customfilter_list (list[CustomFilter]): List of CustomFilter\ 28 | database objects 29 | Returns: 30 | list[CustomFilterRead]: List of CustomFilterRead objects 31 | """ 32 | if not db_customfilter_list or len(db_customfilter_list) == 0: 33 | return [] 34 | customfilter_read_list: list[CustomFilterRead] = [] 35 | for db_customfilter in db_customfilter_list: 36 | customfilter_read = CustomFilterRead.model_validate(db_customfilter) 37 | customfilter_read_list.append(customfilter_read) 38 | return customfilter_read_list 39 | -------------------------------------------------------------------------------- /backend/core/base/database/manager/customfilter/create.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import Session 2 | from core.base.database.manager.customfilter.base import convert_to_read_item 3 | from core.base.database.models.customfilter import ( 4 | CustomFilter, 5 | CustomFilterCreate, 6 | CustomFilterRead, 7 | ) 8 | from core.base.database.models.filter import Filter 9 | from core.base.database.utils.engine import manage_session 10 | 11 | 12 | @manage_session 13 | def create_customfilter( 14 | filter_create: CustomFilterCreate, 15 | *, 16 | _session: Session = None, # type: ignore 17 | ) -> CustomFilterRead: 18 | """ 19 | Create a new custom filter. 20 | Args: 21 | filter_create (CustomFilterCreate): CustomFilterCreate model 22 | _session (Session, optional=None): A session to use for the \ 23 | database connection. A new session is created if not provided. 24 | Returns: 25 | CustomFilterRead: CustomFilterRead object 26 | """ 27 | db_filters: list[Filter] = [] 28 | for filter in filter_create.filters: 29 | db_filters.append(Filter.model_validate(filter)) 30 | filter_create.filters = [] 31 | db_filter = CustomFilter.model_validate(filter_create) 32 | db_filter.filters = db_filters 33 | _session.add(db_filter) 34 | _session.commit() 35 | _session.refresh(db_filter) 36 | return convert_to_read_item(db_filter) 37 | -------------------------------------------------------------------------------- /backend/core/base/database/manager/customfilter/delete.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import Session 2 | 3 | from core.base.database.models.customfilter import CustomFilter 4 | from core.base.database.utils.engine import manage_session 5 | 6 | 7 | @manage_session 8 | def delete_customfilter( 9 | id: int, *, _session: Session = None # type: ignore 10 | ) -> bool: 11 | """ 12 | Delete a Custom filter by id. 13 | Args: 14 | id (int): The id of the view filter to delete. 15 | _session (Session, optional=None): A session to use for the \ 16 | database connection. A new session is created if not provided. 17 | Returns: 18 | bool: True if the Custom filter was deleted successfully. 19 | """ 20 | db_customfilter = _session.get(CustomFilter, id) 21 | if not db_customfilter: 22 | return False 23 | 24 | # Delete all filters associated with the custom filter 25 | for filter in db_customfilter.filters: 26 | _session.delete(filter) 27 | # Delete the custom filter 28 | _session.delete(db_customfilter) 29 | _session.commit() 30 | return True 31 | -------------------------------------------------------------------------------- /backend/core/base/database/manager/customfilter/read.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import Session, select 2 | from core.base.database.manager.customfilter.base import convert_to_read_list 3 | from core.base.database.models.customfilter import ( 4 | CustomFilter, 5 | CustomFilterRead, 6 | FilterType, 7 | ) 8 | from core.base.database.utils.engine import manage_session 9 | 10 | 11 | @manage_session 12 | def get_home_customfilters( 13 | *, 14 | _session: Session = None, # type: ignore 15 | ) -> list[CustomFilterRead]: 16 | """ 17 | Get all home view filters. 18 | Args: 19 | _session (Session, optional=None): A session to use for the \ 20 | database connection. A new session is created if not provided. 21 | Returns: 22 | list[CustomFilterRead]: List of home view filters (read-only). 23 | """ 24 | statement = select(CustomFilter).where( 25 | CustomFilter.filter_type == FilterType.HOME 26 | ) 27 | db_customfilters = _session.exec(statement).all() 28 | return convert_to_read_list(db_customfilters) 29 | 30 | 31 | @manage_session 32 | def get_movie_customfilters( 33 | *, 34 | _session: Session = None, # type: ignore 35 | ) -> list[CustomFilterRead]: 36 | """ 37 | Get all movie view filters. 38 | Args: 39 | _session (Session, optional=None): A session to use for the \ 40 | database connection. A new session is created if not provided. 41 | Returns: 42 | list[CustomFilterRead]: List of movie view filters (read-only). 43 | """ 44 | statement = select(CustomFilter).where( 45 | CustomFilter.filter_type == FilterType.MOVIES 46 | ) 47 | db_customfilters = _session.exec(statement).all() 48 | return convert_to_read_list(db_customfilters) 49 | 50 | 51 | @manage_session 52 | def get_series_customfilters( 53 | *, 54 | _session: Session = None, # type: ignore 55 | ) -> list[CustomFilterRead]: 56 | """ 57 | Get all series view filters. 58 | Args: 59 | _session (Session, optional=None): A session to use for the \ 60 | database connection. A new session is created if not provided. 61 | Returns: 62 | list[CustomFilterRead]: List of series view filters (read-only). 63 | """ 64 | statement = select(CustomFilter).where( 65 | CustomFilter.filter_type == FilterType.SERIES 66 | ) 67 | db_customfilters = _session.exec(statement).all() 68 | return convert_to_read_list(db_customfilters) 69 | -------------------------------------------------------------------------------- /backend/core/base/database/manager/general.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from sqlmodel import Session, col, select 3 | 4 | from core.base.database.models.media import Media 5 | from core.base.database.utils.engine import manage_session 6 | 7 | 8 | class ServerStats(BaseModel): 9 | trailers_downloaded: int 10 | trailers_detected: int 11 | movies_count: int 12 | movies_monitored: int 13 | series_count: int 14 | series_monitored: int 15 | 16 | 17 | class GeneralDatabaseManager: 18 | 19 | @manage_session 20 | def get_stats( 21 | self, 22 | *, 23 | _session: Session = None, # type: ignore 24 | ) -> ServerStats: 25 | # Downloaded trailers count 26 | statement = select(Media.id).where(col(Media.downloaded_at).is_not(None)) 27 | _downloaded = len(_session.exec(statement).all()) 28 | 29 | # Detected trailers count 30 | statement = select(Media.id).where(col(Media.trailer_exists).is_(True)) 31 | _detected = len(_session.exec(statement).all()) 32 | 33 | # Movies Total 34 | movies_statement = select(Media.id).where(col(Media.is_movie).is_(True)) 35 | _movies_count = len(_session.exec(movies_statement).all()) 36 | 37 | # Movies Monitored 38 | statement = movies_statement.where(col(Media.monitor).is_(True)) 39 | _movies_monitored_count = len(_session.exec(statement).all()) 40 | 41 | # Series Total 42 | series_statement = select(Media.id).where(col(Media.is_movie).is_(False)) 43 | _series_count = len(_session.exec(series_statement).all()) 44 | 45 | # Series Monitored 46 | statement = series_statement.where(col(Media.monitor).is_(True)) 47 | _series_monitored_count = len(_session.exec(statement).all()) 48 | 49 | return ServerStats( 50 | trailers_downloaded=_downloaded, 51 | trailers_detected=_detected, 52 | movies_count=_movies_count, 53 | movies_monitored=_movies_monitored_count, 54 | series_count=_series_count, 55 | series_monitored=_series_monitored_count, 56 | ) 57 | -------------------------------------------------------------------------------- /backend/core/base/database/manager/media/__init__.py: -------------------------------------------------------------------------------- 1 | from app_logger import ModuleLogger 2 | 3 | 4 | logger = ModuleLogger("MediaDatabaseManager") 5 | -------------------------------------------------------------------------------- /backend/core/base/database/manager/trailerprofile/__init__.py: -------------------------------------------------------------------------------- 1 | from core.base.database.manager.trailerprofile.create import ( 2 | create_trailerprofile, 3 | ) 4 | from core.base.database.manager.trailerprofile.delete import ( 5 | delete_trailerprofile, 6 | ) 7 | from core.base.database.manager.trailerprofile.read import ( 8 | get_trailer_folders, 9 | get_trailerprofile, 10 | get_trailerprofiles, 11 | ) 12 | from core.base.database.manager.trailerprofile.update import ( 13 | update_trailerprofile, 14 | update_trailerprofile_setting, 15 | ) 16 | 17 | __ALL__ = [ 18 | create_trailerprofile, 19 | delete_trailerprofile, 20 | get_trailerprofile, 21 | get_trailerprofiles, 22 | get_trailer_folders, 23 | update_trailerprofile, 24 | update_trailerprofile_setting, 25 | ] 26 | -------------------------------------------------------------------------------- /backend/core/base/database/manager/trailerprofile/base.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence 2 | 3 | from core.base.database.models.trailerprofile import ( 4 | TrailerProfile, 5 | TrailerProfileRead, 6 | ) 7 | 8 | 9 | def convert_to_read_item( 10 | db_profile: TrailerProfile, 11 | ) -> TrailerProfileRead: 12 | """ 13 | Convert a TrailerProfile database object to a TrailerProfileRead object. 14 | Args: 15 | db_profile (TrailerProfile): TrailerProfile database object 16 | Returns: 17 | TrailerProfileRead: TrailerProfileRead object 18 | """ 19 | trailerprofile_read = TrailerProfileRead.model_validate(db_profile) 20 | return trailerprofile_read 21 | 22 | 23 | def convert_to_read_list( 24 | db_profile_list: Sequence[TrailerProfile], 25 | ) -> list[TrailerProfileRead]: 26 | """ 27 | Convert a list of TrailerProfile database objects to a list of \ 28 | TrailerProfileRead objects. 29 | Args: 30 | db_profile_list (list[TrailerProfile]): List of TrailerProfile\ 31 | database objects 32 | Returns: 33 | list[TrailerProfileRead]: List of TrailerProfileRead objects 34 | """ 35 | if not db_profile_list or len(db_profile_list) == 0: 36 | return [] 37 | trailerprofile_read_list: list[TrailerProfileRead] = [] 38 | for db_profile in db_profile_list: 39 | trailerprofile_read = TrailerProfileRead.model_validate(db_profile) 40 | trailerprofile_read_list.append(trailerprofile_read) 41 | return trailerprofile_read_list 42 | -------------------------------------------------------------------------------- /backend/core/base/database/manager/trailerprofile/create.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import Session 2 | 3 | from app_logger import ModuleLogger 4 | from core.base.database.manager.trailerprofile.base import convert_to_read_item 5 | from core.base.database.models.filter import Filter 6 | from core.base.database.models.trailerprofile import ( 7 | TrailerProfile, 8 | TrailerProfileCreate, 9 | TrailerProfileRead, 10 | ) 11 | from core.base.database.utils.engine import manage_session 12 | 13 | logger = ModuleLogger("TrailerProfileManager") 14 | 15 | 16 | @manage_session 17 | def create_trailerprofile( 18 | trailerprofile_create: TrailerProfileCreate, 19 | *, 20 | _session: Session = None, # type: ignore 21 | ) -> TrailerProfileRead: 22 | """ 23 | Create a new trailer profile. 24 | Args: 25 | trailerprofile_create (TrailerProfileCreate): TrailerProfileCreate model 26 | _session (Session, optional=None): A session to use for the \ 27 | database connection. A new session is created if not provided. 28 | Returns: 29 | TrailerProfileRead: TrailerProfileRead object 30 | Raises: 31 | ValidationError: If the input data is not valid. 32 | """ 33 | # Extract filters from the trailer profile create object 34 | # and create db Filter objects from them 35 | filter_create = trailerprofile_create.customfilter 36 | db_filters: list[Filter] = [] 37 | for filter in filter_create.filters: 38 | db_filters.append(Filter.model_validate(filter)) 39 | # Clear the filters from the trailer profile create object 40 | trailerprofile_create.customfilter.filters = [] 41 | # Create a db TrailerProfile object 42 | db_trailerprofile = TrailerProfile.model_validate(trailerprofile_create) 43 | # Assign the filters to the db TrailerProfile object 44 | db_trailerprofile.customfilter.filters = db_filters 45 | _session.add(db_trailerprofile) 46 | _session.commit() 47 | _session.refresh(db_trailerprofile) 48 | logger.info( 49 | "Created trailer profile:" 50 | f" {db_trailerprofile.customfilter.filter_name}" 51 | ) 52 | return convert_to_read_item(db_trailerprofile) 53 | -------------------------------------------------------------------------------- /backend/core/base/database/manager/trailerprofile/delete.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import Session 2 | 3 | from app_logger import ModuleLogger 4 | from core.base.database.models.trailerprofile import TrailerProfile 5 | from core.base.database.utils.engine import manage_session 6 | 7 | logger = ModuleLogger("TrailerProfileManager") 8 | 9 | 10 | @manage_session 11 | def delete_trailerprofile( 12 | id: int, *, _session: Session = None # type: ignore 13 | ) -> bool: 14 | """ 15 | Delete a trailer profile by id. 16 | Args: 17 | id (int): The id of the trailer profile to delete. 18 | _session (Session, optional=None): A session to use for the \ 19 | database connection. A new session is created if not provided. 20 | Returns: 21 | bool: True if the trailer profile was deleted successfully. 22 | """ 23 | db_trailerprofile = _session.get(TrailerProfile, id) 24 | if not db_trailerprofile: 25 | return False 26 | 27 | # Delete all filters associated with the custom filter 28 | for filter in db_trailerprofile.customfilter.filters: 29 | _session.delete(filter) 30 | # Delete the custom filter associated with the trailer profile 31 | _session.delete(db_trailerprofile.customfilter) 32 | # Delete the trailer profile 33 | _session.delete(db_trailerprofile) 34 | _session.commit() 35 | logger.info( 36 | "Deleted trailer profile:" 37 | f" {db_trailerprofile.customfilter.filter_name}" 38 | ) 39 | return True 40 | -------------------------------------------------------------------------------- /backend/core/base/database/manager/trailerprofile/read.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import Session, select 2 | from core.base.database.manager.trailerprofile.base import ( 3 | convert_to_read_item, 4 | convert_to_read_list, 5 | ) 6 | from core.base.database.models.trailerprofile import ( 7 | TrailerProfile, 8 | TrailerProfileRead, 9 | ) 10 | from core.base.database.utils.engine import manage_session 11 | from exceptions import ItemNotFoundError 12 | 13 | 14 | @manage_session 15 | def get_trailerprofile( 16 | trailerprofile_id: int, 17 | *, 18 | _session: Session = None, # type: ignore 19 | ) -> TrailerProfileRead: 20 | """ 21 | Get a trailer profile by ID. 22 | Args: 23 | trailerprofile_id (int): The ID of the trailer profile to retrieve. 24 | _session (Session, optional=None): A session to use for the \ 25 | database connection. A new session is created if not provided. 26 | Returns: 27 | TrailerProfileRead: The trailer profile (read-only). 28 | Raises: 29 | ItemNotFoundError: If the trailer profile with the given ID is not found. 30 | """ 31 | db_trailerprofile = _session.get(TrailerProfile, trailerprofile_id) 32 | if db_trailerprofile is None: 33 | raise ItemNotFoundError( 34 | model_name="TrailerProfile", id=trailerprofile_id 35 | ) 36 | return convert_to_read_item(db_trailerprofile) 37 | 38 | 39 | @manage_session 40 | def get_trailerprofiles( 41 | *, 42 | _session: Session = None, # type: ignore 43 | ) -> list[TrailerProfileRead]: 44 | """ 45 | Get all trailer profiles. 46 | Args: 47 | _session (Session, optional=None): A session to use for the \ 48 | database connection. A new session is created if not provided. 49 | Returns: 50 | list[TrailerProfileRead]: List of trailer profiles (read-only). 51 | """ 52 | statement = select(TrailerProfile) 53 | db_trailerprofiles = _session.exec(statement).all() 54 | return convert_to_read_list(db_trailerprofiles) 55 | 56 | 57 | @manage_session 58 | def get_trailer_folders( 59 | *, 60 | _session: Session = None, # type: ignore 61 | ) -> set[str]: 62 | """ 63 | Get all Trailer folder names from the database. 64 | Args: 65 | _session (Session, optional=None): A session to use for the \ 66 | database connection. A new session is created if not provided. 67 | Returns: 68 | set[str]: Set of folder names. 69 | """ 70 | statement = select(TrailerProfile.folder_name).distinct() 71 | db_trailerprofiles = _session.exec(statement).all() 72 | return {folder.strip() for folder in db_trailerprofiles} 73 | -------------------------------------------------------------------------------- /backend/core/base/database/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/core/base/database/models/__init__.py -------------------------------------------------------------------------------- /backend/core/base/database/models/customfilter.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from sqlmodel import Field, Relationship, SQLModel 3 | 4 | # if TYPE_CHECKING: 5 | from core.base.database.models.filter import ( 6 | Filter, 7 | FilterCreate, 8 | FilterRead, 9 | ) 10 | 11 | # from core.base.database.models.trailerprofile import ( 12 | # TrailerProfile, 13 | # TrailerProfileCreate, 14 | # ) 15 | 16 | 17 | class FilterType(Enum): 18 | HOME = "HOME" 19 | """For Home CustomFilter""" 20 | MOVIES = "MOVIES" 21 | """For Movies CustomFilter""" 22 | SERIES = "SERIES" 23 | """For Series CustomFilter""" 24 | TRAILER = "TRAILER" 25 | """Only for Trailer Profiles""" 26 | 27 | 28 | class _CustomFilterBase(SQLModel): 29 | """ 30 | Base model for CustomFilter.\n 31 | Note: \n 32 | 🚨DO NOT USE THIS CLASS DIRECTLY.🚨 \n 33 | 👉Use :class:`CustomFilter for working with database.👈 \n 34 | """ 35 | 36 | filter_name: str 37 | filter_type: FilterType = Field(default=FilterType.TRAILER) 38 | """Type of custom filter: 39 | - `Home`: Home view 40 | - `Movies`: Movies view 41 | - `Series`: Series view 42 | - `Trailer`: Trailer view""" 43 | 44 | 45 | class CustomFilter(_CustomFilterBase, table=True): 46 | """ 47 | Database model for CustomFilter.\n 48 | Note: \n 49 | 🚨DO NOT USE THIS CLASS OUTSIDE OF DATABASE MANAGER.🚨 \n 50 | 👉Use :class:`CustomFilterCreate` to create/update view filters.👈 \n 51 | 👉Use :class:`CustomFilterRead` to read the data.👈 52 | """ 53 | 54 | id: int | None = Field(default=None, primary_key=True) 55 | # filters: list["Filter"] = Relationship(back_populates="customfilter") 56 | filters: list[Filter] = Relationship() 57 | """List of filters for the view""" 58 | # trailerprofile: Optional["TrailerProfile"] = Relationship( 59 | # back_populates="customfilter" 60 | # ) 61 | 62 | 63 | class CustomFilterCreate(_CustomFilterBase): 64 | """ 65 | Model for creating/updating CustomFilter. 66 | """ 67 | 68 | id: int | None = None 69 | filters: list[FilterCreate] = [] 70 | """List of filters for the view""" 71 | # trailerprofile: Optional["TrailerProfileCreate"] = None 72 | 73 | 74 | class CustomFilterRead(_CustomFilterBase): 75 | """ 76 | Model for reading CustomFilter. 77 | """ 78 | 79 | id: int 80 | filters: list[FilterRead] 81 | """List of filters for the view""" 82 | -------------------------------------------------------------------------------- /backend/core/base/database/models/helpers.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | from sqlmodel import SQLModel 4 | 5 | from core.base.database.models.media import MonitorStatus 6 | 7 | 8 | @dataclass 9 | class MediaImage: 10 | """Class for working with media images.""" 11 | 12 | id: int 13 | is_poster: bool 14 | image_url: str | None 15 | image_path: str | None 16 | 17 | 18 | # @dataclass 19 | # class MediaTrailer: 20 | # """Class for working with media trailers.""" 21 | 22 | # id: int 23 | # title: str 24 | # is_movie: bool 25 | # language: str 26 | # year: int 27 | # yt_id: str | None 28 | # folder_path: str 29 | # downloaded_at: datetime | None = None 30 | 31 | # def to_dict(self) -> dict: 32 | # """Convert MediaTrailer object to a dictionary.""" 33 | # return asdict(self) 34 | 35 | 36 | # @dataclass(eq=False, frozen=True, repr=False, slots=True) 37 | class MediaReadDC(SQLModel): 38 | id: int 39 | created: bool 40 | folder_path: str | None 41 | arr_monitored: bool 42 | monitor: bool 43 | status: MonitorStatus 44 | trailer_exists: bool 45 | 46 | 47 | @dataclass(eq=False, frozen=True, repr=False, slots=True) 48 | class MediaUpdateDC: 49 | id: int 50 | monitor: bool 51 | status: MonitorStatus 52 | trailer_exists: bool | None = None 53 | yt_id: str | None = None 54 | downloaded_at: datetime | None = None 55 | 56 | 57 | language_names = { 58 | "ar": "Arabic", 59 | "cs": "Czech", 60 | "da": "Danish", 61 | "de": "German", 62 | "el": "Greek", 63 | "en": "English", 64 | "es": "Spanish", 65 | "fi": "Finnish", 66 | "fr": "French", 67 | "he": "Hebrew", 68 | "hi": "Hindi", 69 | "hu": "Hungarian", 70 | "it": "Italian", 71 | "ja": "Japanese", 72 | "kn": "Kannada", 73 | "ko": "Korean", 74 | "ml": "Malayalam", 75 | "nl": "Dutch", 76 | "no": "Norwegian", 77 | "pl": "Polish", 78 | "pt": "Portuguese", 79 | "ru": "Russian", 80 | "sv": "Swedish", 81 | "ta": "Tamil", 82 | "te": "Telugu", 83 | "th": "Thai", 84 | "tr": "Turkish", 85 | "vi": "Vietnamese", 86 | "zh": "Chinese", 87 | } 88 | -------------------------------------------------------------------------------- /backend/core/base/database/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/core/base/database/utils/__init__.py -------------------------------------------------------------------------------- /backend/core/base/database/utils/init_db.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import SQLModel 2 | 3 | # !!! IMPORTANT !!! 4 | # Import all the models that are used in the application so that \ 5 | # SQLModel can create the tables 6 | from core.base.database.models.connection import Connection # noqa: F401 7 | from core.base.database.models.media import Media # noqa: F401 8 | from core.base.database.models.filter import Filter # noqa: F401 9 | from core.base.database.models.customfilter import CustomFilter # noqa: F401 10 | from core.base.database.models.trailerprofile import ( # noqa: F401 11 | TrailerProfile, 12 | ) # noqa: F401 13 | 14 | # from core.base.database.models.filter import ( 15 | # Filter, 16 | # FilterCreate, 17 | # FilterRead, 18 | # ) 19 | # from core.base.database.models.customfilter import ( 20 | # CustomFilter, 21 | # CustomFilterCreate, 22 | # CustomFilterRead, 23 | # ) 24 | # from core.base.database.models.trailerprofile import ( 25 | # TrailerProfile, 26 | # TrailerProfileCreate, 27 | # TrailerProfileRead, 28 | # ) 29 | 30 | # !!! IMPORTANT !!! 31 | # Rebuild models that have relationships after importing all models 32 | # Filter.model_rebuild() 33 | # FilterCreate.model_rebuild() 34 | # FilterRead.model_rebuild() 35 | # CustomFilter.model_rebuild() 36 | # CustomFilterCreate.model_rebuild() 37 | # CustomFilterRead.model_rebuild() 38 | # TrailerProfile.model_rebuild() 39 | # TrailerProfileCreate.model_rebuild() 40 | # TrailerProfileRead.model_rebuild() 41 | 42 | from core.base.database.utils.engine import engine # noqa: E402 43 | 44 | # make sure all SQLModel models are imported (database.models) before\ 45 | # initializing DB. Otherwise, SQLModel might fail to initialize \ 46 | # relationships properly 47 | 48 | 49 | def init_db(): 50 | """Initialize the database and creates tables for SQLModels.""" 51 | # Create the database tables 52 | SQLModel.metadata.create_all(bind=engine) 53 | -------------------------------------------------------------------------------- /backend/core/base/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/core/base/utils/__init__.py -------------------------------------------------------------------------------- /backend/core/download/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/core/download/__init__.py -------------------------------------------------------------------------------- /backend/core/download/cli.py: -------------------------------------------------------------------------------- 1 | # Allow direct execution 2 | import os 3 | import sys 4 | 5 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 6 | 7 | import yt_dlp 8 | import yt_dlp.options 9 | 10 | create_parser = yt_dlp.options.create_parser 11 | 12 | 13 | def parse_patched_options(opts): 14 | patched_parser = create_parser() 15 | patched_parser.defaults.update( 16 | { 17 | "ignoreerrors": False, 18 | "retries": 0, 19 | "fragment_retries": 0, 20 | "extract_flat": False, 21 | "concat_playlist": "never", 22 | } 23 | ) 24 | yt_dlp.options.create_parser = lambda: patched_parser 25 | try: 26 | return yt_dlp.parse_options(opts) 27 | finally: 28 | yt_dlp.options.create_parser = create_parser 29 | 30 | 31 | default_opts = parse_patched_options([]).ydl_opts 32 | 33 | 34 | def cli_to_api(opts, cli_defaults=False): 35 | opts = (yt_dlp.parse_options if cli_defaults else parse_patched_options)( 36 | opts 37 | ).ydl_opts 38 | 39 | diff = {k: v for k, v in opts.items() if default_opts[k] != v} 40 | if "postprocessors" in diff: 41 | diff["postprocessors"] = [ 42 | pp 43 | for pp in diff["postprocessors"] 44 | if pp not in default_opts["postprocessors"] 45 | ] 46 | return diff 47 | 48 | 49 | if __name__ == "__main__": 50 | from pprint import pprint 51 | 52 | print("\nThe arguments passed translate to:\n") 53 | pprint(cli_to_api(sys.argv[1:])) 54 | print("\nCombining these with the CLI defaults gives:\n") 55 | pprint(cli_to_api(sys.argv[1:], True)) 56 | -------------------------------------------------------------------------------- /backend/core/download/trailers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/core/download/trailers/__init__.py -------------------------------------------------------------------------------- /backend/core/download/trailers/batch.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | import time 3 | from app_logger import ModuleLogger 4 | from core.base.database.models.media import MediaRead 5 | from core.base.database.models.trailerprofile import TrailerProfileRead 6 | from core.download.trailer import download_trailer 7 | from exceptions import DownloadFailedError 8 | 9 | logger = ModuleLogger("TrailerDownloadTasks") 10 | 11 | 12 | def batch_download_task( 13 | media_list: list[MediaRead], 14 | profile: TrailerProfileRead, 15 | downloading_count: int | None = None, 16 | download_count: int | None = None, 17 | ) -> None: 18 | """Download trailers for a list of media IDs with given profile. \n 19 | 🚨 This function needs to be called from a background task. 🚨 20 | Args: 21 | media_list (list[MediaRead]): List of media objects to download trailers for. 22 | profile (TrailerProfileRead): The trailer profile to use for download. 23 | downloading_count (int, optional=None): The current downloading count. 24 | download_count (int, optional=None): The total download count. 25 | Returns: 26 | None 27 | """ 28 | if downloading_count is None: 29 | downloading_count = 1 30 | if download_count is None: 31 | download_count = len(media_list) 32 | for media in media_list: 33 | logger.info( 34 | f"Downloading trailer {downloading_count}/{download_count}" 35 | ) 36 | try: 37 | download_trailer(media, profile) 38 | except DownloadFailedError as e: 39 | logger.error(e) 40 | except Exception as e: 41 | logger.error( 42 | "Unexpected error downloading trailer for media" 43 | f" '{media.title}' [{media.id}]: {e}" 44 | ) 45 | finally: 46 | downloading_count += 1 47 | _sleep_for = 100 + randint(0, 50) 48 | logger.debug( 49 | f"Sleeping for {_sleep_for} seconds before next download..." 50 | ) 51 | time.sleep(_sleep_for) 52 | return None 53 | -------------------------------------------------------------------------------- /backend/core/radarr/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/core/radarr/__init__.py -------------------------------------------------------------------------------- /backend/core/radarr/api_manager.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from exceptions import InvalidResponseError 3 | from core.base.arr_manager.base import AsyncBaseArrManager 4 | 5 | 6 | class RadarrManager(AsyncBaseArrManager): 7 | APPNAME = "Radarr" 8 | 9 | def __init__(self, url: str, api_key: str): 10 | """ 11 | Constructor for connection to Radarr API 12 | 13 | Args: 14 | url (str): Host URL to Radarr API 15 | api_key (str): API Key for Radarr API 16 | 17 | Returns: 18 | None 19 | """ 20 | self.version = "v3" 21 | super().__init__(url, api_key, self.version) 22 | 23 | async def get_system_status(self) -> str: 24 | """Get the system status of the Radarr API 25 | 26 | Args: 27 | None 28 | 29 | Returns: 30 | str: The status of the Radarr API with version if successful. 31 | 32 | Raises: 33 | ConnectionError: If the connection is refused / response is not 200 34 | ConnectionTimeoutError: If the connection times out 35 | InvalidResponseError: If API response is invalid 36 | """ 37 | return await self._get_system_status(self.APPNAME) 38 | 39 | # Define Radarr specific API methods here 40 | async def get_all_movies(self) -> list[dict[str, Any]]: 41 | """Get a movie from the Arr API 42 | 43 | Args: 44 | movie_id (int): The ID of the movie to get 45 | 46 | Returns: 47 | list[dict[str, Any]: List of movies from the Radarr API 48 | 49 | Raises: 50 | ConnectionError: If the connection is refused / response is not 200 51 | ConnectionTimeoutError: If the connection times out 52 | InvalidResponseError: If the API response is invalid 53 | """ 54 | movies = await self._request("GET", f"/api/{self.version}/movie") 55 | if isinstance(movies, list): 56 | return movies 57 | raise InvalidResponseError("Invalid response from Radarr API") 58 | 59 | async def get_movie(self, radarr_id: int) -> dict[str, Any]: 60 | """Get a movie from the Arr API 61 | 62 | Args: 63 | radarr_id (int): The ID of the movie to get 64 | 65 | Returns: 66 | dict[str, Any]: Movie from the Radarr API 67 | 68 | Raises: 69 | ConnectionError: If the connection is refused / response is not 200 70 | ConnectionTimeoutError: If the connection times out 71 | InvalidResponseError: If the API response is invalid 72 | """ 73 | movie = await self._request("GET", f"/api/{self.version}/movie/{radarr_id}") 74 | if isinstance(movie, dict): 75 | return movie 76 | raise InvalidResponseError("Invalid response from Radarr API") 77 | 78 | # Define Alias methods here! 79 | get_all_media = get_all_movies 80 | get_media = get_movie 81 | -------------------------------------------------------------------------------- /backend/core/radarr/connection_manager.py: -------------------------------------------------------------------------------- 1 | from core.base.connection_manager import BaseConnectionManager 2 | from core.radarr.data_parser import parse_movie 3 | from core.base.database.models.connection import ConnectionRead 4 | from core.radarr.api_manager import RadarrManager 5 | 6 | 7 | class RadarrConnectionManager(BaseConnectionManager): 8 | """Connection manager for working with the Radarr application.""" 9 | 10 | connection_id: int 11 | 12 | def __init__(self, connection: ConnectionRead): 13 | """Initialize the RadarrConnectionManager. \n 14 | Args: 15 | connection (ConnectionRead): The connection data.""" 16 | radarr_manager = RadarrManager(connection.url, connection.api_key) 17 | self.connection_id = connection.id 18 | super().__init__( 19 | connection, 20 | radarr_manager, 21 | parse_movie, 22 | # inline_trailer=True, 23 | is_movie=True, 24 | ) 25 | -------------------------------------------------------------------------------- /backend/core/radarr/database_manager.py: -------------------------------------------------------------------------------- 1 | # from core.base.database.manager.base import DatabaseManager 2 | # from core.radarr.models import Movie, MovieCreate, MovieRead, MovieUpdate 3 | 4 | 5 | # class MovieDatabaseManager(DatabaseManager[Movie, MovieCreate, MovieRead, MovieUpdate]): 6 | # """CRUD operations for movie database table.""" 7 | 8 | # def __init__(self): 9 | # super().__init__(db_model=Movie, read_model=MovieRead) 10 | -------------------------------------------------------------------------------- /backend/core/radarr/models.py: -------------------------------------------------------------------------------- 1 | # from sqlmodel import Field 2 | 3 | # from core.base.database.models.media import ( 4 | # MediaCreate, 5 | # MediaDB, 6 | # MediaRead, 7 | # MediaUpdate, 8 | # ) 9 | 10 | 11 | # class Movie(MediaDB): 12 | # """Movie model for the database. This is the main model for the application. 13 | 14 | # Note: 15 | 16 | # **DO NOT USE THIS CLASS DIRECTLY.** 17 | 18 | # Use MovieCreate, MovieRead, or MovieUpdate instead. 19 | # """ 20 | 21 | # id: int | None = Field(default=None, primary_key=True) 22 | # connection_id: int = Field(foreign_key="connection.id", index=True) 23 | # is_movie: bool = True 24 | # arr_id: int = Field(alias="radarr_id", index=True) 25 | # txdb_id: str = Field(alias="tmdb_id", index=True) 26 | # arr_monitored: bool = Field(alias="radarr_monitored", default=False) 27 | 28 | 29 | # class MovieCreate(MediaCreate): 30 | # """Movie model for creating a new movie. This is used in the API while creating. 31 | 32 | # Defaults: 33 | # - year: current year 34 | # - language: "en" 35 | # - runtime: 0 36 | # - trailer_exists: False 37 | # - monitor: False 38 | # - arr_monitored: False 39 | # """ 40 | 41 | # arr_id: int = Field(alias="radarr_id") 42 | # txdb_id: str = Field(alias="tmdb_id") 43 | # arr_monitored: bool = Field(alias="radarr_monitored", default=False) 44 | 45 | 46 | # class MovieRead(MediaRead): 47 | # """Movie model for reading a movie. This is used in the API to return data.""" 48 | 49 | # arr_id: int = Field(alias="radarr_id") 50 | # txdb_id: str = Field(alias="tmdb_id") 51 | # arr_monitored: bool = Field(alias="radarr_monitored", default=False) 52 | 53 | 54 | # class MovieUpdate(MediaUpdate): 55 | # """Movie model for updating a movie. This is used in the API while updating. 56 | 57 | # Defaults: 58 | # - updated_at: current time [if any field is updated] 59 | # """ 60 | 61 | # arr_id: int | None = Field(alias="radarr_id", default=None) 62 | # txdb_id: str | None = Field(alias="tmdb_id", default=None) 63 | # arr_monitored: bool | None = Field(alias="radarr_monitored", default=None) 64 | -------------------------------------------------------------------------------- /backend/core/sonarr/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/core/sonarr/__init__.py -------------------------------------------------------------------------------- /backend/core/sonarr/api_manager.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from exceptions import InvalidResponseError 3 | from core.base.arr_manager.base import AsyncBaseArrManager 4 | 5 | 6 | class SonarrManager(AsyncBaseArrManager): 7 | APPNAME = "Sonarr" 8 | 9 | def __init__(self, url: str, api_key: str): 10 | """ 11 | Constructor for connection to Sonarr API 12 | 13 | Args: 14 | url (str): Host URL to Sonarr API 15 | api_key (str): API Key for Sonarr API 16 | 17 | Returns: 18 | None 19 | """ 20 | self.version = "v3" 21 | super().__init__(url, api_key, self.version) 22 | 23 | async def get_system_status(self) -> str: 24 | """Get the system status of the Sonarr API 25 | 26 | Returns: 27 | str: The status of the Sonarr API with version. 28 | 29 | Raises: 30 | ConnectionError: If the connection is refused / response is not 200 31 | ConnectionTimeoutError: If the connection times out 32 | InvalidResponseError: If API response is invalid 33 | """ 34 | return await self._get_system_status(self.APPNAME) 35 | 36 | # Define Sonarr specific API methods here 37 | 38 | async def get_all_series(self) -> list[dict[str, Any]]: 39 | """Get all series from the Sonarr API 40 | 41 | Returns: 42 | list[dict[str, Any]]: List of series from the Sonarr API 43 | 44 | Raises: 45 | ConnectionError: If the connection is refused / response is not 200 46 | ConnectionTimeoutError: If the connection times out 47 | InvalidResponseError: If the API response is invalid 48 | """ 49 | series = await self._request("GET", f"/api/{self.version}/series") 50 | if isinstance(series, list): 51 | return series 52 | raise InvalidResponseError("Invalid response from Sonarr API") 53 | 54 | async def get_series(self, sonarr_id: int) -> dict[str, Any]: 55 | """Get a series from the Sonarr API 56 | 57 | Args: 58 | sonarr_id (int): The ID of the series to get 59 | 60 | Returns: 61 | dict[str, Any]: Series from the Sonarr API 62 | 63 | Raises: 64 | ConnectionError: If the connection is refused / response is not 200 65 | ConnectionTimeoutError: If the connection times out 66 | InvalidResponseError: If the API response is invalid 67 | """ 68 | series = await self._request("GET", f"/api/{self.version}/series/{sonarr_id}") 69 | if isinstance(series, dict): 70 | return series 71 | raise InvalidResponseError("Invalid response from Sonarr API") 72 | 73 | # Define Alias methods here! 74 | get_all_media = get_all_series 75 | get_media = get_series 76 | -------------------------------------------------------------------------------- /backend/core/sonarr/connection_manager.py: -------------------------------------------------------------------------------- 1 | from core.base.connection_manager import BaseConnectionManager 2 | from core.sonarr.data_parser import parse_series 3 | from core.base.database.models.connection import ConnectionRead 4 | from core.sonarr.api_manager import SonarrManager 5 | 6 | 7 | class SonarrConnectionManager(BaseConnectionManager): 8 | """Connection manager for working with the Sonarr application.""" 9 | 10 | connection_id: int 11 | 12 | def __init__(self, connection: ConnectionRead): 13 | """Initialize the SonarrConnectionManager. \n 14 | Args: 15 | connection (ConnectionRead): The connection data.""" 16 | sonarr_manager = SonarrManager(connection.url, connection.api_key) 17 | self.connection_id = connection.id 18 | super().__init__( 19 | connection, 20 | sonarr_manager, 21 | parse_series, 22 | # inline_trailer=False, 23 | is_movie=False, 24 | ) 25 | -------------------------------------------------------------------------------- /backend/core/sonarr/database_manager.py: -------------------------------------------------------------------------------- 1 | # from core.base.database.manager.base import MediaDatabaseManager 2 | # from core.sonarr.models import ( 3 | # Series, 4 | # SeriesCreate, 5 | # SeriesRead, 6 | # SeriesUpdate, 7 | # ) 8 | 9 | 10 | # class SeriesDatabaseManager( 11 | # MediaDatabaseManager[Series, SeriesCreate, SeriesRead, SeriesUpdate] 12 | # ): 13 | # """CRUD operations for series database table.""" 14 | 15 | # def __init__(self): 16 | # super().__init__(db_model=Series, read_model=SeriesRead) 17 | -------------------------------------------------------------------------------- /backend/core/sonarr/models.py: -------------------------------------------------------------------------------- 1 | # from sqlmodel import Field 2 | 3 | # from core.base.database.models.media import ( 4 | # MediaCreate, 5 | # MediaDB, 6 | # MediaRead, 7 | # MediaUpdate, 8 | # ) 9 | 10 | 11 | # class Series(MediaDB): 12 | # """Series model for the database. This is the main model for the application. 13 | 14 | # Note: 15 | 16 | # **DO NOT USE THIS CLASS DIRECTLY.** 17 | 18 | # Use SeriesCreate, SeriesRead, or SeriesUpdate instead. 19 | # """ 20 | 21 | # id: int | None = Field(default=None, primary_key=True) 22 | # connection_id: int = Field(foreign_key="connection.id", index=True) 23 | # is_movie: bool = False 24 | # arr_id: int = Field(alias="sonarr_id", index=True) 25 | # txdb_id: str = Field(alias="tvdb_id", index=True) 26 | # arr_monitored: bool = Field(alias="sonarr_monitored", default=False) 27 | 28 | 29 | # class SeriesCreate(MediaCreate): 30 | # """Series model for creating a new series. This is used in the API while creating. 31 | 32 | # Defaults: 33 | # - year: current year 34 | # - language: "en" 35 | # - runtime: 0 36 | # - trailer_exists: False 37 | # - monitor: False 38 | # - sonarr_monitored: False 39 | # """ 40 | 41 | # arr_id: int = Field(alias="sonarr_id") 42 | # txdb_id: str = Field(alias="tvdb_id") 43 | # arr_monitored: bool = Field(alias="sonarr_monitored", default=False) 44 | 45 | 46 | # class SeriesRead(MediaRead): 47 | # """Series model for reading a series. This is used in the API to return data.""" 48 | 49 | # arr_id: int = Field(alias="sonarr_id") 50 | # txdb_id: str = Field(alias="tvdb_id") 51 | # arr_monitored: bool = Field(alias="sonarr_monitored", default=False) 52 | 53 | 54 | # class SeriesUpdate(MediaUpdate): 55 | # """Series model for updating a series. This is used in the API while updating. 56 | 57 | # Defaults: 58 | # - updated_at: current time [if any field is updated] 59 | # """ 60 | 61 | # arr_id: int = Field(alias="sonarr_id", default=None) 62 | # txdb_id: str = Field(alias="tvdb_id", default=None) 63 | # arr_monitored: bool = Field(alias="sonarr_monitored", default=False) 64 | -------------------------------------------------------------------------------- /backend/core/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from apscheduler.schedulers.background import BackgroundScheduler 3 | from apscheduler.executors.pool import ThreadPoolExecutor, ProcessPoolExecutor 4 | from apscheduler.jobstores.memory import MemoryJobStore 5 | 6 | from app_logger import ModuleLogger 7 | from core.tasks.task_logging import add_all_event_listeners 8 | 9 | # Get the timezone from the environment variable 10 | timezone = os.getenv("TZ", "UTC") 11 | 12 | # Initialize a MemeoryJobStore for the scheduler 13 | jobstores = {"default": MemoryJobStore()} 14 | 15 | # Configure executors 16 | executors = { 17 | "default": ThreadPoolExecutor(10), 18 | "processpool": ProcessPoolExecutor(10), 19 | } 20 | 21 | # Get a logger 22 | tasks_logger = ModuleLogger("Tasks") 23 | 24 | # Create a scheduler instance and start it in FastAPI's lifespan context 25 | scheduler = BackgroundScheduler( 26 | jobstores=jobstores, timezone=timezone, logger=tasks_logger 27 | ) 28 | 29 | add_all_event_listeners(scheduler) 30 | -------------------------------------------------------------------------------- /backend/core/updates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/core/updates/__init__.py -------------------------------------------------------------------------------- /backend/core/updates/docker_check.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from app_logger import ModuleLogger 4 | from config.settings import app_settings 5 | 6 | logger = ModuleLogger("UpdateChecker") 7 | 8 | 9 | def get_latest_image_version(image_name) -> str | None: 10 | """Gets the latest release tag from Github API. 11 | Args: 12 | image_name (str): The name of the Docker image on Docker Hub. \n 13 | Example: "library/ubuntu" \n 14 | Returns: 15 | str|None: The latest release tag of the image.\n 16 | Example: "v0.2.0" 17 | """ 18 | try: 19 | url = f"https://api.github.com/repos/{image_name}/releases/latest" 20 | response = requests.get(url) 21 | response.raise_for_status() # Raise an error for bad responses 22 | release_data = response.json() 23 | return release_data["tag_name"] 24 | except Exception as e: 25 | logger.exception(f"Error fetching release info from Github API: {e}") 26 | return None 27 | 28 | 29 | def get_current_image_version(): 30 | """Gets the current version of the image running in the container.""" 31 | # Implement logic to get the current image version 32 | # This could be an environment variable or a file in your container 33 | return app_settings.version 34 | 35 | 36 | def check_for_update(): 37 | image_name = "nandyalu/trailarr" 38 | current_version = get_current_image_version() 39 | latest_version = get_latest_image_version(image_name) 40 | 41 | if not latest_version: 42 | return 43 | 44 | if current_version != latest_version: 45 | logger.info( 46 | f"A newer version ({latest_version}) of the image is available." 47 | " Please update!" 48 | ) 49 | app_settings.update_available = True 50 | else: 51 | logger.info( 52 | f"You are using the latest version ({latest_version}) of the" 53 | " image." 54 | ) 55 | 56 | 57 | if __name__ == "__main__": 58 | check_for_update() 59 | -------------------------------------------------------------------------------- /backend/exceptions.py: -------------------------------------------------------------------------------- 1 | class ConnectionTimeoutError(Exception): 2 | """Raised when a connection times out""" 3 | 4 | pass 5 | 6 | 7 | class ConversionFailedError(Exception): 8 | """Raised when a video conversion fails""" 9 | 10 | pass 11 | 12 | 13 | class DownloadFailedError(Exception): 14 | """Raised when a video download fails""" 15 | 16 | pass 17 | 18 | 19 | class FolderPathEmptyError(Exception): 20 | """Raised when a folder path is empty or invalid""" 21 | 22 | pass 23 | 24 | 25 | class FolderNotFoundError(Exception): 26 | """Raised when a folder is not found""" 27 | 28 | def __init__(self, folder_path: str) -> None: 29 | message = f"Folder not found in the system: {folder_path}" 30 | super().__init__(message) 31 | 32 | pass 33 | 34 | 35 | class InvalidResponseError(Exception): 36 | """Raised when a connection returns an invalid response""" 37 | 38 | pass 39 | 40 | 41 | class ItemExistsError(Exception): 42 | """Raised when an Item already exists in the database""" 43 | 44 | pass 45 | 46 | 47 | class ItemNotFoundError(Exception): 48 | """Raised when an Item is not found in the database""" 49 | 50 | def __init__(self, model_name: str, id: int) -> None: 51 | message = f"{model_name} with id {id} not found" 52 | super().__init__(message) 53 | 54 | pass 55 | -------------------------------------------------------------------------------- /backend/export_openapi.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import yaml 5 | 6 | from main import trailarr_api 7 | 8 | print("Exporting OpenAPI documentation...") 9 | # Save JSON to "/app/docs/help/api/openapi.json" 10 | OUTPUT_JSON = Path("/app/docs/help/api") / "openapi.json" 11 | OUTPUT_JSON.write_text(json.dumps(trailarr_api.openapi(), indent=None)) 12 | 13 | print(f"OpenAPI documentation exported to {OUTPUT_JSON}") 14 | 15 | 16 | class MyDumper(yaml.Dumper): 17 | """Helper class to format YAML output.""" 18 | 19 | def increase_indent(self, flow=False, indentless=False): 20 | return super(MyDumper, self).increase_indent(flow, False) 21 | 22 | 23 | # Save YAML to "/app/frontend/contract/swagger.yaml" 24 | OUTPUT_JSON = Path("/app/frontend/contract") / "swagger.yaml" 25 | yaml.dump( 26 | trailarr_api.openapi(), 27 | open(OUTPUT_JSON, "w"), 28 | Dumper=MyDumper, 29 | sort_keys=False, 30 | allow_unicode=True, 31 | default_flow_style=False, 32 | ) 33 | print(f"OpenAPI documentation exported to {OUTPUT_JSON}") 34 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | # Backend 2 | aiohttp==3.11.14 3 | aiofiles==24.1.0 4 | alembic==1.15.2 5 | apscheduler==3.11.0 6 | async-lru==2.0.5 7 | fastapi[standard]==0.115.12 # Update version in README.md as well 8 | bcrypt==4.3.0 9 | pillow==11.1.0 10 | sqlmodel==0.0.24 11 | yt-dlp[default]==2025.3.27 -------------------------------------------------------------------------------- /backend/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/tests/__init__.py -------------------------------------------------------------------------------- /backend/tests/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/tests/api/__init__.py -------------------------------------------------------------------------------- /backend/tests/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/tests/config/__init__.py -------------------------------------------------------------------------------- /backend/tests/config/test_config.py: -------------------------------------------------------------------------------- 1 | from config.settings import app_settings 2 | 3 | 4 | class TestConfig: 5 | 6 | def test_trailer_resolution_valid_value(self): 7 | app_settings.trailer_resolution = "720" # Valid value 8 | assert app_settings.trailer_resolution == 720 9 | 10 | def test_trailer_resolution_invalid_value(self): 11 | app_settings.trailer_resolution = "721" # Invalid value 12 | assert app_settings.trailer_resolution == 720 13 | 14 | def test_trailer_resolution_invalid_string(self): 15 | app_settings.trailer_resolution = "abcd" # Invalid value 16 | assert app_settings.trailer_resolution == app_settings._DEFAULT_RESOLUTION 17 | 18 | def test_trailer_resolution_valid_with_pixels(self): 19 | app_settings.trailer_resolution = "2160p" # value with 'p' 20 | assert app_settings.trailer_resolution == 2160 21 | 22 | def test_trailer_resolution_valid_name(self): 23 | app_settings.trailer_resolution = "QHD" # resolution name 24 | assert app_settings.trailer_resolution == 1440 25 | 26 | def test_trailer_audio_format(self): 27 | app_settings.trailer_audio_format = "some format" # Invalid value 28 | assert app_settings.trailer_audio_format == "aac" 29 | 30 | def test_trailer_video_format(self): 31 | app_settings.trailer_video_format = "some format" # Invalid value 32 | assert app_settings.trailer_video_format == "h264" 33 | 34 | def test_trailer_file_format(self): 35 | app_settings.trailer_file_format = "some format" # Invalid value 36 | assert app_settings.trailer_file_format == "mkv" 37 | -------------------------------------------------------------------------------- /backend/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode, urljoin 2 | from aioresponses import aioresponses 3 | import pytest 4 | 5 | 6 | # TODO! Update all tests to current codebase 7 | def pytest_configure(): 8 | # os.environ["TESTING"] = "True" 9 | from core.base.database.utils.init_db import init_db 10 | 11 | init_db() 12 | 13 | 14 | @pytest.fixture(autouse=True) 15 | def debug_database(): 16 | # os.environ["TESTING"] = "True" 17 | pass 18 | 19 | 20 | TEST_AIOHTTP_APIKEY = "API_KEY" 21 | TEST_AIOHTTP_URL = "http://example.com" 22 | TEST_AIOHTTP_PATH = "example" 23 | TEST_AIOHTTP_PARAMS = {"param1": "value1"} 24 | TEST_AIOHTTP_HEADERS = {"X-Api-Key": TEST_AIOHTTP_APIKEY} 25 | TEST_AIOHTTP_RESPONSE = {"some_key": "some_value"} 26 | _URL_W_PATH = urljoin(TEST_AIOHTTP_URL, TEST_AIOHTTP_PATH) 27 | _URL_QUERY = urlencode(TEST_AIOHTTP_PARAMS) 28 | TEST_AIOHTTP_FINAL_URL = f"{_URL_W_PATH}?{_URL_QUERY}" 29 | 30 | 31 | @pytest.fixture 32 | def debug_aiohttp_200(): 33 | with aioresponses() as m: 34 | url = TEST_AIOHTTP_FINAL_URL 35 | headers = TEST_AIOHTTP_HEADERS 36 | payload = TEST_AIOHTTP_RESPONSE 37 | m.get(url, status=200, payload=payload, headers=headers) 38 | m.post(url, status=200, payload=payload, headers=headers) 39 | m.put(url, status=200, payload=payload, headers=headers) 40 | m.delete(url, status=200, payload=payload, headers=headers) 41 | yield m 42 | 43 | 44 | @pytest.fixture 45 | def debug_aiohttp(): 46 | with aioresponses() as m: 47 | # TODO: figure out how to pass in the exception to raise and raise that 48 | yield m 49 | -------------------------------------------------------------------------------- /backend/tests/database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/tests/database/__init__.py -------------------------------------------------------------------------------- /backend/tests/database/crud/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/tests/database/crud/__init__.py -------------------------------------------------------------------------------- /backend/tests/database/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/tests/database/utils/__init__.py -------------------------------------------------------------------------------- /backend/tests/database/utils/test_init_db.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import patch 3 | from core.base.database.utils.init_db import init_db 4 | 5 | 6 | class TestInitDB(TestCase): 7 | @patch("backend.database.utils.init_db.SQLModel") 8 | @patch("backend.database.utils.init_db.engine") 9 | def test_init_db(self, mock_engine, mock_sqlmodel): 10 | init_db() 11 | mock_sqlmodel.metadata.create_all.assert_called_once_with(bind=mock_engine) 12 | -------------------------------------------------------------------------------- /backend/tests/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/tests/services/__init__.py -------------------------------------------------------------------------------- /backend/tests/services/arr_manager/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandyalu/trailarr/51dfcfff42243eb96e6884195c912991f59ada7a/backend/tests/services/arr_manager/__init__.py -------------------------------------------------------------------------------- /backend/tests/services/arr_manager/test_radarr.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from exceptions import InvalidResponseError 3 | from core.radarr.api_manager import RadarrManager 4 | from tests import conftest 5 | 6 | 7 | class TestRadarrManager: 8 | URL = conftest.TEST_AIOHTTP_URL 9 | API_KEY = conftest.TEST_AIOHTTP_APIKEY 10 | radarr_manager = RadarrManager(URL, API_KEY) 11 | 12 | @pytest.mark.asyncio 13 | async def test_get_system_status_success(self, debug_aiohttp): 14 | # Arrange 15 | debug_aiohttp.get( 16 | f"{self.URL}/api/v3/system/status", 17 | status=200, 18 | payload={"appName": "Radarr", "version": "3.0.0"}, 19 | ) 20 | 21 | # Act 22 | result = await self.radarr_manager.get_system_status() 23 | 24 | # Assert 25 | assert result == "Radarr Connection Successful! Version: 3.0.0" 26 | 27 | @pytest.mark.asyncio 28 | async def test_get_system_status_exception(self, debug_aiohttp): 29 | # Arrange 30 | debug_aiohttp.get( 31 | f"{self.URL}/api/v3/system/status", 32 | status=200, 33 | payload={"appName": "WrongApp", "version": "3.0.0"}, 34 | ) 35 | 36 | # Act 37 | with pytest.raises(InvalidResponseError) as e: 38 | await self.radarr_manager.get_system_status() 39 | 40 | # Assert 41 | _error = f"Invalid Host ({self.URL}) or API Key ({self.API_KEY}), not a Radarr instance." 42 | assert str(e.value) == _error 43 | assert e.type == InvalidResponseError 44 | -------------------------------------------------------------------------------- /backend/tests/services/arr_manager/test_sonarr.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from exceptions import InvalidResponseError 3 | from core.sonarr.api_manager import SonarrManager 4 | from tests import conftest 5 | 6 | 7 | class TestSonarrManager: 8 | 9 | URL = conftest.TEST_AIOHTTP_URL 10 | API_KEY = conftest.TEST_AIOHTTP_APIKEY 11 | sonarr_manager = SonarrManager(URL, API_KEY) 12 | 13 | @pytest.mark.asyncio 14 | async def test_get_system_status_success(self, debug_aiohttp): 15 | # Arrange 16 | debug_aiohttp.get( 17 | f"{self.URL}/api/v3/system/status", 18 | status=200, 19 | payload={"appName": "Sonarr", "version": "3.0.0"}, 20 | ) 21 | 22 | # Act 23 | result = await self.sonarr_manager.get_system_status() 24 | 25 | # Assert 26 | assert result == "Sonarr Connection Successful! Version: 3.0.0" 27 | 28 | @pytest.mark.asyncio 29 | async def test_get_system_status_exception(self, debug_aiohttp): 30 | # Arrange 31 | debug_aiohttp.get( 32 | f"{self.URL}/api/v3/system/status", 33 | status=200, 34 | payload={"appName": "WrongApp", "version": "3.0.0"}, 35 | ) 36 | 37 | # Act 38 | with pytest.raises(InvalidResponseError) as e: 39 | await self.sonarr_manager.get_system_status() 40 | 41 | # Assert 42 | _error = f"Invalid Host ({self.URL}) or API Key ({self.API_KEY}), not a Sonarr instance." 43 | assert str(e.value) == _error 44 | assert e.type == InvalidResponseError 45 | -------------------------------------------------------------------------------- /docs/help/api/docs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 |