├── .dockerignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── docker_hub_build.yml │ ├── docker_hub_build_nightly.yml │ └── stale.yml ├── CLAUDE.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── api_service ├── __init__.py ├── app.py ├── automate_process.py ├── blueprints │ ├── automation │ │ ├── __init__.py │ │ └── routes.py │ ├── config │ │ ├── __init__.py │ │ └── routes.py │ ├── jellyfin │ │ ├── __init__.py │ │ └── routes.py │ ├── logs │ │ ├── __init__.py │ │ └── routes.py │ ├── plex │ │ ├── __init__.py │ │ └── routes.py │ └── seer │ │ ├── __init__.py │ │ └── routes.py ├── config │ ├── __init__.py │ ├── config.py │ ├── cron_jobs.py │ └── logger_manager.py ├── conftest.py ├── db │ └── database_manager.py ├── exceptions │ └── database_exceptions.py ├── handler │ ├── jellyfin_handler.py │ └── plex_handler.py ├── requirements.dev.txt ├── requirements.txt ├── services │ ├── jellyfin │ │ ├── __init__.py │ │ └── jellyfin_client.py │ ├── jellyseer │ │ ├── __init__.py │ │ └── seer_client.py │ ├── plex │ │ ├── __init__.py │ │ ├── plex_auth.py │ │ └── plex_client.py │ └── tmdb │ │ ├── __init__.py │ │ └── tmdb_client.py ├── tasks │ ├── __init__.py │ └── tasks.py ├── test │ ├── test.py │ └── test_config.py └── utils │ ├── __init__.py │ ├── clients.py │ ├── error_handlers.py │ └── utils.py ├── client ├── README.md ├── babel.config.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── images │ │ ├── default1.jpg │ │ ├── default2.jpg │ │ └── default3.jpg │ └── index.html ├── src │ ├── App.vue │ ├── api │ │ ├── api.js │ │ ├── backgroundManager.js │ │ ├── plexApi.js │ │ └── tmdbApi.js │ ├── assets │ │ ├── logo.png │ │ ├── logos │ │ │ ├── emby-logo.png │ │ │ ├── jellyfin-logo.png │ │ │ └── plex-logo.png │ │ ├── styles │ │ │ ├── advancedFilterConfig.css │ │ │ └── wizard.css │ │ └── tailwind.css │ ├── components │ │ ├── AppFooter.vue │ │ ├── ConfigSummary.vue │ │ ├── ConfigWizard.vue │ │ ├── LogsComponent.vue │ │ ├── RequestsPage.vue │ │ └── configWizard │ │ │ ├── AdditionalSettings.vue │ │ │ ├── ContentFilterSettings.vue │ │ │ ├── DbConfig.vue │ │ │ ├── JellyfinConfig.vue │ │ │ ├── MediaServiceSelection.vue │ │ │ ├── PlexConfig.vue │ │ │ ├── SeerConfig.vue │ │ │ └── TmdbConfig.vue │ ├── main.js │ └── router │ │ └── index.js └── vue.config.js ├── config └── supervisord.conf ├── docker ├── Dockerfile ├── docker-compose.yml └── docker_entrypoint.sh └── unraid ├── ca_profile.xml ├── logo.png └── template.xml /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore Git files and folders 2 | .git 3 | .gitattributes 4 | .gitignore 5 | 6 | # Ignore Docker-specific files and folders 7 | .docker 8 | docker-compose.yml 9 | docker-compose.override.yml 10 | docker-compose-debug.yml 11 | dockerfile 12 | .dockerignore 13 | 14 | # Ignore environment and config files 15 | .env 16 | .env.local 17 | .env.development 18 | .env.production 19 | .env.example 20 | *.config 21 | *.yaml 22 | 23 | # Ignore Python cache and environment folders 24 | **/__pycache__/ 25 | **/*.py[cod] 26 | .Python 27 | .venv/ 28 | venv/ 29 | 30 | # Ignore Node modules 31 | node_modules/ 32 | 33 | # Ignore IDE and editor configurations and temporary files 34 | .vscode/ 35 | .idea/ 36 | *.sublime-project 37 | *.sublime-workspace 38 | *.iml 39 | 40 | # Ignore temporary and backup files 41 | *.swp 42 | *.swo 43 | *.bak 44 | *.tmp 45 | *.temp 46 | *.DS_Store 47 | 48 | # Ignore log files 49 | *.log 50 | app.log 51 | 52 | # Ignore build and distribution folders 53 | build/ 54 | dist/ 55 | out/ 56 | 57 | # Ignore documentation and markdown files 58 | *.md 59 | 60 | # Ignore config and config files folders 61 | /config_files 62 | /config/config_files -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: suggestarr 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: suggestarr 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.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 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[REQ]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/docker_hub_build.yml: -------------------------------------------------------------------------------- 1 | name: Bump Version, Build and Publish Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | bump-version: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | new_version: ${{ steps.bump_version.outputs.new_version }} 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4.2.2 17 | 18 | - name: Set up Node.js 19 | uses: actions/setup-node@v4.1.0 20 | with: 21 | node-version: '20' 22 | 23 | - name: Ensure Dependencies are Installed 24 | run: | 25 | npm install -g semver 26 | sudo apt-get install jq -y 27 | 28 | - name: Strip 'v' prefix and bump version in package.json 29 | id: bump_version 30 | run: | 31 | cd client/ 32 | current_version=$(jq -r '.version' package.json | sed 's/^v//') 33 | new_version=$(npx semver $current_version -i patch) 34 | prefixed_version="v$new_version" 35 | echo "Bumping version from v$current_version to $prefixed_version" 36 | jq ".version = \"$prefixed_version\"" package.json > package_tmp.json && mv package_tmp.json package.json 37 | echo "new_version=$prefixed_version" >> $GITHUB_ENV 38 | echo "::set-output name=new_version::$prefixed_version" 39 | 40 | - name: Commit and push new version 41 | run: | 42 | git config --global user.name "github-actions[bot]" 43 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 44 | git add client/package.json 45 | git commit -m "chore: bump version to ${{ steps.bump_version.outputs.new_version }}" 46 | git tag -a "${{ steps.bump_version.outputs.new_version }}" -m "Release ${{ steps.bump_version.outputs.new_version }}" 47 | git pull origin main --rebase 48 | git push origin main 49 | git push origin "${{ steps.bump_version.outputs.new_version }}" 50 | 51 | build: 52 | needs: bump-version 53 | runs-on: ubuntu-latest 54 | 55 | steps: 56 | - name: Checkout code 57 | uses: actions/checkout@v4.2.2 58 | 59 | - name: Pull latest changes 60 | run: git pull origin main 61 | 62 | - name: Set up Docker Buildx 63 | uses: docker/setup-buildx-action@v3.7.1 64 | 65 | - name: Log in to Docker Hub 66 | run: echo ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} | docker login -u ciuse99 --password-stdin 67 | 68 | - name: Build and Push Docker Image 69 | env: 70 | new_version: ${{ needs.bump-version.outputs.new_version }} 71 | run: | 72 | echo "New version tag: $new_version" 73 | docker buildx build \ 74 | --platform linux/amd64,linux/arm64 \ 75 | --cache-from type=registry,ref=ciuse99/suggestarr:cache \ 76 | --cache-to type=registry,ref=ciuse99/suggestarr:cache,mode=max \ 77 | -t ciuse99/suggestarr:latest \ 78 | -t ciuse99/suggestarr:$new_version \ 79 | -f docker/Dockerfile . --push 80 | 81 | recreate-nightly: 82 | needs: build 83 | runs-on: ubuntu-latest 84 | steps: 85 | - name: Checkout code 86 | uses: actions/checkout@v4.2.2 87 | 88 | - name: Recreate nightly branch 89 | run: | 90 | git fetch origin 91 | git branch -D nightly || true 92 | git push origin --delete nightly || true 93 | git checkout -b nightly 94 | git push origin nightly 95 | -------------------------------------------------------------------------------- /.github/workflows/docker_hub_build_nightly.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Docker Image to Docker Hub 2 | 3 | on: 4 | push: 5 | branches: 6 | - nightly 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4.2.2 15 | 16 | - name: Set up Docker Buildx 17 | uses: docker/setup-buildx-action@v3.7.1 18 | 19 | - name: Log in to Docker Hub 20 | run: echo ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} | docker login -u ciuse99 --password-stdin 21 | 22 | - name: Build and Push Docker Image 23 | run: | 24 | docker buildx build \ 25 | --platform linux/amd64,linux/arm64 \ 26 | --cache-from type=registry,ref=ciuse99/suggestarr:cache \ 27 | --cache-to type=registry,ref=ciuse99/suggestarr:cache,mode=max \ 28 | -t ciuse99/suggestarr:nightly \ 29 | -f docker/Dockerfile . --push 30 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close stale issues with 'bug' label 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | stale: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Close stale issues with the 'bug' label 13 | uses: actions/stale@v8 14 | with: 15 | days-before-stale: 5 # Number of days of inactivity before marking an issue as stale 16 | days-before-close: 0 # Number of days to close an issue after it's marked stale 17 | stale-issue-message: 'This issue has been automatically closed due to inactivity.' 18 | only-labels: 'bug' # Only applies to issues with the 'bug' label 19 | stale-issue-label: 'stale' # Adds a 'stale' label (or choose a different label) 20 | close-issue-message: 'Automatically closed after 5 days of inactivity.' 21 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # SuggestArr Development Guide 2 | 3 | ## Build & Run Commands 4 | - Backend: `docker build . -f ./docker/Dockerfile --target dev --tag suggestarr:nightly` 5 | - Frontend serve: `cd client && npm run serve` 6 | - Frontend build: `cd client && npm run build --skip-plugins @vue/cli-plugin-eslint` 7 | - Frontend lint: `cd client && npm run lint` 8 | - Run tests: `cd api_service && python -m pytest` 9 | - Run single test: `cd api_service && python -m pytest test/test_file.py::TestClass::test_function -v` 10 | 11 | ## Code Style Guidelines 12 | - Python: PEP 8, docstrings with detailed param/return descriptions 13 | - Vue: ESLint with Vue3-essential and ESLint recommended configs 14 | - Python naming: snake_case for functions/variables, PascalCase for classes 15 | - JavaScript naming: camelCase for variables/functions, PascalCase for components 16 | - Error handling: Use custom exceptions from exceptions/ directory 17 | - Logging: Use `logger = LoggerManager.get_logger(__name__)` pattern 18 | 19 | ## Architecture Notes 20 | - Backend: Flask-based API with RESTful endpoints in blueprints/ 21 | - Frontend: Vue.js 3 with component-based architecture 22 | - Testing: pytest for backend, unit tests with detailed assertions 23 | - Documentation: Include docstrings for all functions/classes -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Jellyseer Request Automation 2 | 3 | Thank you for considering contributing to this project! Here's how you can get started: 4 | 5 | ## How to Contribute: 6 | - **Report Issues**: If you find bugs or issues, please open an issue on GitHub. 7 | - **Suggest Features**: Have an idea for a new feature? We'd love to hear it! Open an issue with your proposal. 8 | - **Submit Pull Requests**: If you want to fix a bug or implement a feature, feel free to submit a pull request. Please ensure your code follows the coding standards. 9 | - **Improve Documentation**: You can contribute by improving this documentation or adding new sections to it. 10 | 11 | ## Guidelines: 12 | 1. Fork the repository and create your branch from `main`. 13 | 2. If you've added code that should be tested, add tests. 14 | 3. Ensure the code is well-documented. 15 | 4. Open a pull request and provide a clear explanation of the changes. 16 | 17 | ## Building the Docker Image 18 | To build the Docker image for development, run the following commands: 19 | ```bash 20 | docker build . -f ./docker/Dockerfile --target dev --tag suggestarr:nightly 21 | ``` -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2024 Giuseppe 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | ## 🚀 SuggestArr - Media Automation Made Simple 4 | 5 | ![ezgif com-optimize (2)](https://github.com/user-attachments/assets/d5c48bdb-3c11-4f35-bb55-849297d521e7) 6 | 7 | ![Build Status](https://img.shields.io/github/actions/workflow/status/giuseppe99barchetta/suggestarr/docker_hub_build.yml?branch=main&label=Build&logo=github) 8 | ![Latest Release](https://img.shields.io/github/v/release/giuseppe99barchetta/suggestarr?include_prereleases&label=Latest%20Release&logo=github) 9 | [![Documentation](https://img.shields.io/badge/Docs-Available-blue?logo=readthedocs)](https://github.com/giuseppe99barchetta/SuggestArr/wiki) 10 | ![Platform Support](https://img.shields.io/badge/platforms-linux%2Famd64%20|%20linux%2Farm64-blue?logo=linux) 11 | ![Docker Pulls](https://img.shields.io/docker/pulls/ciuse99/suggestarr?label=Docker%20Pulls&logo=docker) 12 | 13 | [![Buy Me a Coffee](https://img.shields.io/badge/Donate-Buy%20Me%20a%20Coffee-orange?logo=buy-me-a-coffee)](https://buymeacoffee.com/suggestarr) 14 | [![Reddit Upvotes](https://img.shields.io/badge/Reddit-Upvotes-ff4500?logo=reddit)](https://www.reddit.com/r/selfhosted/comments/1gb4swg/release_major_update_for_suggestarr_now/) 15 | ![Last Commit](https://img.shields.io/github/last-commit/giuseppe99barchetta/suggestarr?label=Last%20Commit&logo=github) 16 | [![](https://dcbadge.limes.pink/api/server/https://discord.gg/JXwFd3PnXY?style=flat)](https://discord.gg/JXwFd3PnXY) 17 |
18 | 19 | SuggestArr is a project designed to automate media content recommendations and download requests based on user activity in media servers like **Jellyfin**, **Plex**, and now **Emby**. It retrieves recently watched content, searches for similar titles using the TMDb API, and sends automated download requests to **Jellyseer** or **Overseer**. 20 | 21 | ## Features 22 | - **Multi-Media Server Support**: Supports Jellyfin, Plex, and Emby for retrieving media content. 23 | - **TMDb Integration**: Searches for similar movies and TV shows on TMDb. 24 | - **Automated Requests**: Sends download requests for recommended content to Jellyseer or Overseer. 25 | - **Web Interface**: A user-friendly interface for configuration and management. 26 | - **Real-Time Logs**: View and filter logs in real time (e.g., `INFO`, `ERROR`, `DEBUG`). 27 | - **User Selection**: Choose specific users to initiate requests, allowing management and approval of auto-requested content. 28 | - **Cron Job Management**: Update the cron job schedule directly from the web interface. 29 | - **Configuration Pre-testing**: Automatically validates API keys and URLs during setup. 30 | - **Content Filtering**: Exclude requests for content already available on streaming platforms in your country. 31 | - **External Database Support**: Use external databases (PostgreSQL, MySQL) in addition to SQLite for improved scalability and performance. 32 | 33 | ## Prerequisites 34 | - **Python 3.x** or **Docker** 35 | - **[TMDb API Key](https://www.themoviedb.org/documentation/api)** 36 | - Configured **[Jellyfin](https://jellyfin.org/)**, **[Plex](https://www.plex.tv/)**, or **[Emby](https://emby.media/)** 37 | - Configured **[Jellyseer](https://github.com/Fallenbagel/jellyseerr)** or **[Overseer](https://github.com/sct/overseerr)** 38 | - (Optional) External database (PostgreSQL or MySQL) for improved performance 39 | 40 | ## Docker Usage 41 | 42 | You can run the project using Docker Compose for easy setup and execution. 43 | 44 | ### Docker Compose Example 45 | 46 | ```yaml 47 | services: 48 | suggestarr: 49 | image: ciuse99/suggestarr:latest 50 | container_name: SuggestArr 51 | restart: always 52 | ports: 53 | - "${SUGGESTARR_PORT:-5000}:${SUGGESTARR_PORT:-5000}" 54 | volumes: 55 | - ./config_files:/app/config/config_files 56 | environment: 57 | # Optional: Only needed if something goes wrong and you need to inspect deeper 58 | - LOG_LEVEL=${LOG_LEVEL:-info} 59 | # Optional: Customize the port (defaults to 5000 if not set) 60 | - SUGGESTARR_PORT=${SUGGESTARR_PORT:-5000} 61 | ``` 62 | To start the container with Docker Compose: 63 | 64 | ```bash 65 | docker-compose up 66 | ``` 67 | 68 | ## Web Interface 69 | 70 | Access the web interface at: http://localhost:5000 (or your custom port if configured with SUGGESTARR_PORT). Use this interface to configure the application, select your media service, and manage cron schedules. 71 | 72 | Make sure your environment is set up correctly and that the application is running to access the web interface. 73 | 74 | ### Using a Specific Jellyseer/Overseer User for Requests 75 | If you'd like to use a specific Jellyseer user to make media requests, follow these steps: 76 | 77 | 1. In the web interface, enable the user selection option by checking the corresponding box. 78 | 2. Select the desired user from the dropdown list. 79 | 3. Enter the password for the selected user. 80 | 4. The system will now use this user to make media requests, rather than using the admin or default profile. 81 | 82 | Note: Currently, only local Jellyseer users are supported. 83 | 84 | ## Running Without Docker 85 | For detailed instructions on setting up SuggestArr withouth Docker or as a system service, please refer to our [Installation Guide](https://github.com/giuseppe99barchetta/SuggestArr/wiki/Installation#documentation-to-run-the-project-without-docker). 86 | 87 | ## Join Our Discord Community 88 | Feel free to join our Discord community to share ideas, ask questions, or get help with SuggestArr: [Join here](https://discord.gg/cpjBJ5sK). 89 | 90 | ## Contribute 91 | Contributions are highly welcome! Feel free to open issues, submit pull requests, or provide any feedback that can improve the project. Whether you're fixing bugs, improving documentation, or adding new features, all contributions are greatly appreciated. 92 | 93 | ## License 94 | This project is licensed under the MIT License. 95 | 96 | -------------------------------------------------------------------------------- /api_service/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/api_service/__init__.py -------------------------------------------------------------------------------- /api_service/app.py: -------------------------------------------------------------------------------- 1 | """ 2 | Main Flask application for managing environment variables and running processes. 3 | """ 4 | from concurrent.futures import ThreadPoolExecutor 5 | import os 6 | from flask import Flask, send_from_directory 7 | from flask_cors import CORS 8 | from asgiref.wsgi import WsgiToAsgi 9 | import logging 10 | import atexit 11 | 12 | from api_service.utils.utils import AppUtils 13 | from api_service.config.logger_manager import LoggerManager 14 | from api_service.config.config import load_env_vars 15 | 16 | from api_service.blueprints.jellyfin.routes import jellyfin_bp 17 | from api_service.blueprints.seer.routes import seer_bp 18 | from api_service.blueprints.plex.routes import plex_bp 19 | from api_service.blueprints.automation.routes import automation_bp 20 | from api_service.blueprints.logs.routes import logs_bp 21 | from api_service.blueprints.config.routes import config_bp 22 | 23 | executor = ThreadPoolExecutor(max_workers=3) 24 | logger = LoggerManager.get_logger("APP") 25 | logger.info(f"Current log level: {logging.getLevelName(logger.getEffectiveLevel())}") 26 | 27 | # App Factory Pattern for modularity and testability 28 | def create_app(): 29 | """ 30 | Create and configure the Flask application. 31 | """ 32 | 33 | if AppUtils.is_last_worker(): 34 | AppUtils.print_welcome_message() # Print only for last worker 35 | 36 | application = Flask(__name__, static_folder='../static', static_url_path='/') 37 | CORS(application) 38 | 39 | application.register_blueprint(jellyfin_bp, url_prefix='/api/jellyfin') 40 | application.register_blueprint(seer_bp, url_prefix='/api/seer') 41 | application.register_blueprint(plex_bp, url_prefix='/api/plex') 42 | application.register_blueprint(automation_bp, url_prefix='/api/automation') 43 | application.register_blueprint(logs_bp, url_prefix='/api') 44 | application.register_blueprint(config_bp, url_prefix='/api/config') 45 | 46 | # Register routes 47 | register_routes(application) 48 | 49 | # Load environment variables at startup 50 | AppUtils.load_environment() 51 | 52 | return application 53 | 54 | def register_routes(app): # pylint: disable=redefined-outer-name 55 | """ 56 | Register the application routes. 57 | """ 58 | 59 | @app.route('/', defaults={'path': ''}) 60 | @app.route('/') 61 | def serve_frontend(path): 62 | """ 63 | Serve the built frontend's index.html or any other static file. 64 | """ 65 | app.static_folder = '../static' 66 | if path == "" or not os.path.exists(os.path.join(app.static_folder, path)): 67 | return send_from_directory(app.static_folder, 'index.html') 68 | else: 69 | # Serve the requested file (static assets like JS, CSS, images, etc.) 70 | return send_from_directory(app.static_folder, path) 71 | 72 | app = create_app() 73 | asgi_app = WsgiToAsgi(app) 74 | env_vars = load_env_vars() 75 | if env_vars.get('CRON_TIMES'): 76 | from api_service.config.cron_jobs import start_cron_job 77 | start_cron_job(env_vars) 78 | 79 | def close_log_handlers(): 80 | for handler in logging.root.handlers[:]: 81 | handler.close() 82 | logging.root.removeHandler(handler) 83 | atexit.register(close_log_handlers) 84 | 85 | if __name__ == '__main__': 86 | port = int(os.environ.get('SUGGESTARR_PORT', 5000)) 87 | app.run(host='0.0.0.0', port=port) -------------------------------------------------------------------------------- /api_service/automate_process.py: -------------------------------------------------------------------------------- 1 | from api_service.config.config import load_env_vars 2 | from api_service.config.logger_manager import LoggerManager 3 | from api_service.handler.jellyfin_handler import JellyfinHandler 4 | from api_service.handler.plex_handler import PlexHandler 5 | from api_service.services.jellyfin.jellyfin_client import JellyfinClient 6 | from api_service.services.jellyseer.seer_client import SeerClient 7 | from api_service.services.plex.plex_client import PlexClient 8 | from api_service.services.tmdb.tmdb_client import TMDbClient 9 | 10 | 11 | class ContentAutomation: 12 | """ 13 | Automates the process of retrieving recent movies/TV shows from Jellyfin/Plex, 14 | finding similar content via TMDb, and requesting content via Jellyseer/Overseer. 15 | """ 16 | 17 | def __new__(cls): 18 | """Override to prevent direct instantiation and enforce use of `create`.""" 19 | instance = super(ContentAutomation, cls).__new__(cls) 20 | instance.logger = LoggerManager.get_logger(cls.__name__) 21 | return instance 22 | 23 | @classmethod 24 | async def create(cls): 25 | """Async factory method to initialize ContentAutomation asynchronously.""" 26 | instance = cls.__new__(cls) 27 | env_vars = load_env_vars() 28 | 29 | instance.selected_service = env_vars['SELECTED_SERVICE'] 30 | instance.max_content = env_vars.get('MAX_CONTENT_CHECKS', 10) 31 | instance.max_similar_movie = min(int(env_vars.get('MAX_SIMILAR_MOVIE', '3')), 20) 32 | instance.max_similar_tv = min(int(env_vars.get('MAX_SIMILAR_TV', '2')), 20) 33 | instance.search_size = min(int(env_vars.get('SEARCH_SIZE', '20')), 100) 34 | instance.number_of_seasons = env_vars.get('FILTER_NUM_SEASONS') or "all" 35 | instance.selected_users = env_vars.get('SELECTED_USERS') or [] 36 | 37 | # TMDB filters 38 | tmdb_threshold = int(env_vars.get('FILTER_TMDB_THRESHOLD') or 60) 39 | tmdb_min_votes = int(env_vars.get('FILTER_TMDB_MIN_VOTES') or 20) 40 | include_no_ratings = env_vars.get('FILTER_INCLUDE_NO_RATING', True) == True 41 | filter_release_year = int(env_vars.get('FILTER_RELEASE_YEAR') or 0) 42 | filter_language = env_vars.get('FILTER_LANGUAGE', []) 43 | filter_genre = env_vars.get('FILTER_GENRES_EXCLUDE', []) 44 | filter_region_provider = env_vars.get('FILTER_REGION_PROVIDER', None) 45 | filter_streaming_services = env_vars.get('FILTER_STREAMING_SERVICES', []) 46 | 47 | # Overseer/Jellyseer client 48 | jellyseer_client = SeerClient( 49 | env_vars['SEER_API_URL'], 50 | env_vars['SEER_TOKEN'], 51 | env_vars['SEER_USER_NAME'], 52 | env_vars['SEER_USER_PSW'], 53 | env_vars['SEER_SESSION_TOKEN'], 54 | instance.number_of_seasons 55 | ) 56 | await jellyseer_client.init() 57 | 58 | # TMDb client 59 | tmdb_client = TMDbClient( 60 | env_vars['TMDB_API_KEY'], 61 | instance.search_size, 62 | tmdb_threshold, 63 | tmdb_min_votes, 64 | include_no_ratings, 65 | filter_release_year, 66 | filter_language, 67 | filter_genre, 68 | filter_region_provider, 69 | filter_streaming_services 70 | ) 71 | 72 | # Initialize media service handler (Jellyfin or Plex) 73 | if instance.selected_service in ('jellyfin', 'emby'): 74 | jellyfin_client = JellyfinClient( 75 | env_vars['JELLYFIN_API_URL'], 76 | env_vars['JELLYFIN_TOKEN'], 77 | instance.max_content, 78 | env_vars.get('JELLYFIN_LIBRARIES') 79 | ) 80 | await jellyfin_client.init_existing_content() 81 | instance.media_handler = JellyfinHandler( 82 | jellyfin_client, jellyseer_client, tmdb_client, instance.logger, instance.max_similar_movie, instance.max_similar_tv, instance.selected_users 83 | ) 84 | 85 | elif instance.selected_service == 'plex': 86 | plex_client = PlexClient( 87 | api_url=env_vars['PLEX_API_URL'], 88 | token=env_vars['PLEX_TOKEN'], 89 | max_content=instance.max_content, 90 | library_ids=env_vars.get('PLEX_LIBRARIES'), 91 | user_ids=instance.selected_users 92 | ) 93 | await plex_client.init_existing_content() 94 | instance.media_handler = PlexHandler(plex_client, jellyseer_client, tmdb_client, instance.logger, instance.max_similar_movie, instance.max_similar_tv) 95 | 96 | return instance 97 | 98 | async def run(self): 99 | """Main entry point to start the automation process.""" 100 | await self.media_handler.process_recent_items() 101 | -------------------------------------------------------------------------------- /api_service/blueprints/automation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/api_service/blueprints/automation/__init__.py -------------------------------------------------------------------------------- /api_service/blueprints/automation/routes.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify, request 2 | from api_service.automate_process import ContentAutomation 3 | from api_service.config.logger_manager import LoggerManager 4 | from api_service.db.database_manager import DatabaseManager 5 | 6 | logger = LoggerManager().get_logger("AutomationRoute") 7 | automation_bp = Blueprint('automation', __name__) 8 | 9 | @automation_bp.route('/force_run', methods=['POST']) 10 | async def run_now(): 11 | """ 12 | Endpoint to execute the process in the background. 13 | """ 14 | try: 15 | content_automation = await ContentAutomation.create() 16 | await content_automation.run() 17 | return jsonify({'status': 'success', 'message': 'Task is running in the background!'}), 202 18 | except ValueError as ve: 19 | logger.error(f'Value error: {str(ve)}') 20 | return jsonify({'status': 'error', 'message': 'Value error: ' + str(ve)}), 400 21 | except FileNotFoundError as fnfe: 22 | logger.error(f'File not found: {str(fnfe)}') 23 | return jsonify({'status': 'error', 'message': 'File not found: ' + str(fnfe)}), 404 24 | except Exception as e: 25 | logger.error(f'Unexpected error: {str(e)}') 26 | return jsonify({'status': 'error', 'message': 'Unexpected error: ' + str(e)}), 500 27 | 28 | @automation_bp.route('/requests', methods=['GET']) 29 | def get_all_requests(): 30 | page = request.args.get('page', default=1, type=int) 31 | per_page = request.args.get('per_page', default=5, type=int) 32 | db_manager = DatabaseManager() 33 | return jsonify(db_manager.get_all_requests_grouped_by_source(page=page, per_page=per_page)) -------------------------------------------------------------------------------- /api_service/blueprints/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/api_service/blueprints/config/__init__.py -------------------------------------------------------------------------------- /api_service/blueprints/config/routes.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import Blueprint, request, jsonify 3 | import yaml 4 | from api_service.config.config import load_env_vars, save_env_vars, clear_env_vars 5 | from api_service.config.logger_manager import LoggerManager 6 | from api_service.db.database_manager import DatabaseManager 7 | 8 | logger = LoggerManager().get_logger("ConfigRoute") 9 | config_bp = Blueprint('config', __name__) 10 | 11 | @config_bp.route('/fetch', methods=['GET']) 12 | def fetch_config(): 13 | """ 14 | Load current configuration in JSON format. 15 | """ 16 | try: 17 | config = load_env_vars() 18 | return jsonify(config), 200 19 | except Exception as e: 20 | logger.error(f'Error loading configuration: {str(e)}') 21 | return jsonify({'message': f'Error loading configuration: {str(e)}', 'status': 'error'}), 500 22 | 23 | @config_bp.route('/save', methods=['POST']) 24 | def save_config(): 25 | """ 26 | Save environment variables. 27 | """ 28 | try: 29 | config_data = request.json 30 | save_env_vars(config_data) 31 | DatabaseManager().initialize_db() 32 | return jsonify({'message': 'Configuration saved successfully!', 'status': 'success'}), 200 33 | except Exception as e: 34 | logger.error(f'Error saving configuration: {str(e)}') 35 | return jsonify({'message': f'Error saving configuration: {str(e)}', 'status': 'error'}), 500 36 | 37 | @config_bp.route('/reset', methods=['POST']) 38 | def reset_config(): 39 | """ 40 | Reset environment variables. 41 | """ 42 | try: 43 | clear_env_vars() 44 | return jsonify({'message': 'Configuration cleared successfully!', 'status': 'success'}), 200 45 | except Exception as e: 46 | logger.error(f'Error clearing configuration: {str(e)}') 47 | return jsonify({'message': f'Error clearing configuration: {str(e)}', 'status': 'error'}), 500 48 | 49 | @config_bp.route('/test-db-connection', methods=['POST']) 50 | def test_db_connection(): 51 | """ 52 | Test database connection. 53 | """ 54 | try: 55 | # Extract DB configuration data from the request 56 | db_config = request.json 57 | 58 | # Check if the necessary data has been provided 59 | required_keys = ['DB_TYPE', 'DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'] 60 | if any(key not in db_config for key in required_keys): 61 | return jsonify({'message': 'Missing required database configuration parameters.', 'status': 'error'}), 400 62 | 63 | # Create an instance of the DatabaseManager 64 | db_manager = DatabaseManager() 65 | 66 | # Call the connection test method 67 | result = db_manager.test_connection(db_config) 68 | 69 | # Respond with the test result 70 | return jsonify(result), 200 if result['status'] == 'success' else 500 71 | 72 | except Exception as e: 73 | logger.error(f'Error testing database connection: {str(e)}') 74 | return jsonify({'message': f'Error testing database connection: {str(e)}', 'status': 'error'}), 500 -------------------------------------------------------------------------------- /api_service/blueprints/jellyfin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/api_service/blueprints/jellyfin/__init__.py -------------------------------------------------------------------------------- /api_service/blueprints/jellyfin/routes.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request, jsonify 2 | from api_service.services.jellyfin.jellyfin_client import JellyfinClient 3 | from api_service.config.logger_manager import LoggerManager 4 | 5 | logger = LoggerManager().get_logger("JellyfinRoute") 6 | jellyfin_bp = Blueprint('jellyfin', __name__) 7 | 8 | @jellyfin_bp.route('/libraries', methods=['POST']) 9 | async def get_jellyfin_library(): 10 | """ 11 | Fetch Jellyfin libraries using the provided API key and server URL. 12 | """ 13 | try: 14 | config_data = request.json 15 | api_url = config_data.get('JELLYFIN_API_URL') 16 | api_key = config_data.get('JELLYFIN_TOKEN') 17 | 18 | jellyfin_client = JellyfinClient(api_url=api_url, token=api_key) 19 | 20 | libraries = await jellyfin_client.get_libraries() 21 | 22 | if libraries: 23 | return jsonify({'message': 'Libraries fetched successfully', 'items': libraries}), 200 24 | return jsonify({'message': 'No library found', 'type': 'error'}), 404 25 | except Exception as e: 26 | logger.error(f'Error fetching Jellyfin libraries: {str(e)}') 27 | return jsonify({'message': f'Error fetching Jellyfin libraries: {str(e)}', 'type': 'error'}), 500 28 | 29 | @jellyfin_bp.route('/users', methods=['POST']) 30 | async def get_jellyfin_users(): 31 | """ 32 | Fetch Jellyfin users using the provided API key and server URL. 33 | """ 34 | try: 35 | config_data = request.json 36 | api_url = config_data.get('JELLYFIN_API_URL') 37 | api_key = config_data.get('JELLYFIN_TOKEN') 38 | 39 | jellyfin_client = JellyfinClient(api_url=api_url, token=api_key) 40 | 41 | users = await jellyfin_client.get_all_users() 42 | if users: 43 | return jsonify({'message': 'Users fetched successfully', 'users': users}), 200 44 | return jsonify({'message': 'No users found', 'type': 'error'}), 404 45 | except Exception as e: 46 | logger.error(f'Error fetching Jellyfin users: {str(e)}') -------------------------------------------------------------------------------- /api_service/blueprints/logs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/api_service/blueprints/logs/__init__.py -------------------------------------------------------------------------------- /api_service/blueprints/logs/routes.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import Blueprint, jsonify 3 | from api_service.config.logger_manager import LoggerManager 4 | 5 | logger = LoggerManager().get_logger("LogsRoute") 6 | logs_bp = Blueprint('logs', __name__) 7 | 8 | @logs_bp.route('/logs', methods=['GET']) 9 | def get_logs(): 10 | """ 11 | Endpoint to retrieve logs. 12 | """ 13 | logs = read_logs() 14 | return jsonify(logs), 200 15 | 16 | def read_logs(log_file='app.log'): 17 | """ 18 | Function to read log content from the specified log file. 19 | """ 20 | try: 21 | base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../config/config_files')) 22 | log_file = os.path.join(base_dir, log_file) 23 | with open(log_file, 'r', encoding='utf-8') as f: 24 | logs = f.readlines() 25 | return logs 26 | except Exception as e: 27 | logger.error(f'Error reading logs: {str(e)}') 28 | return [] 29 | -------------------------------------------------------------------------------- /api_service/blueprints/plex/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/api_service/blueprints/plex/__init__.py -------------------------------------------------------------------------------- /api_service/blueprints/plex/routes.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | from flask import Blueprint, request, jsonify 4 | from api_service.services.plex.plex_auth import PlexAuth 5 | from api_service.services.plex.plex_client import PlexClient 6 | from api_service.config.logger_manager import LoggerManager 7 | 8 | logger = LoggerManager().get_logger("PlexRoute") 9 | plex_bp = Blueprint('plex', __name__) 10 | client_id = os.getenv('PLEX_CLIENT_ID', str(uuid.uuid4())) 11 | 12 | @plex_bp.route('/libraries', methods=['POST']) 13 | async def get_plex_libraries(): 14 | """ 15 | Fetch Plex libraries using the provided API key and server URL. 16 | """ 17 | try: 18 | config_data = request.json 19 | api_url = config_data.get('PLEX_API_URL') 20 | api_token = config_data.get('PLEX_TOKEN') 21 | 22 | if not api_url or not api_token: 23 | return jsonify({'message': 'API URL and token are required', 'type': 'error'}), 400 24 | 25 | plex_client = PlexClient(api_url=api_url, token=api_token) 26 | libraries = await plex_client.get_libraries() 27 | 28 | if not libraries: 29 | return jsonify({'message': 'No library found', 'type': 'error'}), 404 30 | 31 | return jsonify({'message': 'Libraries fetched successfully', 'items': libraries}), 200 32 | except Exception as e: 33 | logger.error(f'Error fetching Plex libraries: {str(e)}') 34 | return jsonify({'message': f'Error fetching Plex libraries: {str(e)}', 'type': 'error'}), 500 35 | 36 | 37 | @plex_bp.route('/auth', methods=['POST']) 38 | def plex_login(): 39 | plex_auth = PlexAuth(client_id=client_id) 40 | pin_id, auth_url = plex_auth.get_authentication_pin() 41 | return jsonify({'pin_id': pin_id, 'auth_url': auth_url}) 42 | 43 | @plex_bp.route('/callback', methods=['POST']) 44 | def check_plex_authentication(): 45 | pin_id = request.json.get('pin_id') 46 | plex_auth = PlexAuth(client_id=client_id) 47 | 48 | auth_token = plex_auth.check_authentication(pin_id) 49 | 50 | if auth_token: 51 | return jsonify({'auth_token': auth_token}) 52 | else: 53 | return jsonify({'error': 'Authentication failed'}), 401 54 | 55 | @plex_bp.route('/api/v1/auth/plex', methods=['POST']) 56 | def login_with_plex(): 57 | auth_token = request.json.get('authToken') 58 | 59 | if auth_token: 60 | return jsonify({'message': 'Login success', 'auth_token': auth_token}) 61 | else: 62 | return jsonify({'error': 'Invalid token'}), 401 63 | 64 | @plex_bp.route('/check-auth/', methods=['GET']) 65 | def check_plex_auth(pin_id): 66 | """Verifica se il login su Plex è stato completato e ottieni il token.""" 67 | plex_auth = PlexAuth(client_id=client_id) 68 | auth_token = plex_auth.check_authentication(pin_id) 69 | 70 | if auth_token: 71 | return jsonify({'auth_token': auth_token}) 72 | else: 73 | return jsonify({'auth_token': None}), 200 74 | 75 | @plex_bp.route('/servers', methods=['POST']) 76 | async def get_plex_servers_async_route(): 77 | """ 78 | Find all available Plex servers. 79 | """ 80 | try: 81 | auth_token = request.json.get('auth_token') 82 | 83 | if not auth_token: 84 | return jsonify({'message': 'Auth token is required', 'type': 'error'}), 400 85 | 86 | plex_client = PlexClient(token=auth_token, client_id=os.getenv('PLEX_CLIENT_ID', str(uuid.uuid4()))) 87 | servers = await plex_client.get_servers() 88 | 89 | if servers: 90 | return jsonify({'message': 'Plex servers fetched successfully', 'servers': servers}), 200 91 | else: 92 | return jsonify({'message': 'Failed to fetch Plex servers', 'type': 'error'}), 404 93 | 94 | except Exception as e: 95 | print(f"Errore durante il recupero dei server Plex: {str(e)}") 96 | return jsonify({'message': f'Error fetching Plex servers: {str(e)}', 'type': 'error'}), 500 97 | 98 | @plex_bp.route('/users', methods=['POST']) 99 | async def get_plex_users(): 100 | """ 101 | Fetch Plex users using the provided API token. 102 | """ 103 | try: 104 | config_data = request.json 105 | api_token = config_data.get('PLEX_TOKEN') 106 | api_url = config_data.get('PLEX_API_URL') 107 | 108 | if not api_token: 109 | return jsonify({'message': 'API token is required', 'type': 'error'}), 400 110 | 111 | plex_client = PlexClient(token=api_token, client_id=client_id, api_url=api_url) 112 | users = await plex_client.get_all_users() 113 | 114 | if not users: 115 | return jsonify({'message': 'No users found', 'type': 'error'}), 404 116 | 117 | return jsonify({'message': 'Users fetched successfully', 'users': users}), 200 118 | except Exception as e: 119 | logger.error(f'Error fetching Plex users: {str(e)}') 120 | return jsonify({'message': f'Error fetching Plex users: {str(e)}', 'type': 'error'}), 500 -------------------------------------------------------------------------------- /api_service/blueprints/seer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/api_service/blueprints/seer/__init__.py -------------------------------------------------------------------------------- /api_service/blueprints/seer/routes.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request, jsonify 2 | from api_service.services.jellyseer.seer_client import SeerClient 3 | from api_service.config.logger_manager import LoggerManager 4 | 5 | logger = LoggerManager().get_logger("SeerRoute") 6 | seer_bp = Blueprint('seer', __name__) 7 | 8 | @seer_bp.route('/get_users', methods=['POST']) 9 | async def get_users(): 10 | """ 11 | Fetch Jellyseer/Overseer users using the provided API key. 12 | """ 13 | try: 14 | config_data = request.json 15 | api_url = config_data.get('SEER_API_URL') 16 | api_key = config_data.get('SEER_TOKEN') 17 | session_token = config_data.get('SEER_SESSION_TOKEN') 18 | 19 | if not api_key: 20 | return jsonify({'message': 'API key is required', 'type': 'error'}), 400 21 | 22 | # Initialize JellyseerClient with the provided API key 23 | jellyseer_client = SeerClient(api_url=api_url, api_key=api_key, session_token=session_token) 24 | users = await jellyseer_client.get_all_users() 25 | 26 | if not users: 27 | return jsonify({'message': 'Failed to fetch users', 'type': 'error'}), 404 28 | 29 | return jsonify({'message': 'Users fetched successfully', 'users': users}), 200 30 | except Exception as e: 31 | logger.error(f'Error fetching Jellyseer/Overseer users: {str(e)}') 32 | return jsonify({'message': f'Error fetching users: {str(e)}', 'type': 'error'}), 500 33 | 34 | @seer_bp.route('/login', methods=['POST']) 35 | async def login_seer(): 36 | """ 37 | Endpoint to log in to Jellyseer/Overseer using the provided credentials. 38 | """ 39 | try: 40 | config_data = request.json 41 | api_url = config_data.get('SEER_API_URL') 42 | api_key = config_data.get('SEER_TOKEN') 43 | username = config_data.get('SEER_USER_NAME') 44 | password = config_data.get('SEER_PASSWORD') 45 | 46 | if not username or not password: 47 | return jsonify({'message': 'Username and password are required', 'type': 'error'}), 400 48 | 49 | # Initialize the Jellyseer/Overseer client with the credentials provided 50 | jellyseer_client = SeerClient( 51 | api_url=api_url, api_key=api_key, seer_user_name=username, seer_password=password 52 | ) 53 | 54 | # Perform the login 55 | await jellyseer_client.login() 56 | 57 | # Check if the login was successful by verifying the session token 58 | if jellyseer_client.session_token: 59 | return jsonify({ 60 | 'message': 'Login successful', 61 | 'type': 'success', 62 | 'session_token': jellyseer_client.session_token 63 | }), 200 64 | else: 65 | return jsonify({'message': 'Login failed', 'type': 'error'}), 401 66 | 67 | except Exception as e: 68 | logger.error(f'An error occurred during login: {str(e)}') 69 | return jsonify({'message': 'An internal error has occurred', 'type': 'error'}), 500 -------------------------------------------------------------------------------- /api_service/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/api_service/config/__init__.py -------------------------------------------------------------------------------- /api_service/config/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import yaml 3 | from croniter import croniter 4 | from api_service.config.logger_manager import LoggerManager 5 | from api_service.config.cron_jobs import start_cron_job 6 | 7 | logger = LoggerManager().get_logger("Config") 8 | 9 | # Constants for environment variables 10 | BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) 11 | CONFIG_PATH = os.path.join(BASE_DIR, 'config', 'config_files', 'config.yaml') 12 | 13 | def load_env_vars(): 14 | """ 15 | Load variables from the config.yaml file and return them as a dictionary. 16 | """ 17 | logger.debug("Loading environment variables from config.yaml") 18 | if not os.path.exists(CONFIG_PATH): 19 | logger.warning(f"{CONFIG_PATH} not found. Creating a new one with default values.") 20 | return get_config_values() 21 | 22 | with open(CONFIG_PATH, 'r', encoding='utf-8') as file: 23 | config_data = yaml.safe_load(file) 24 | logger.debug("Correctly loaded stored config.yaml") 25 | return {key: config_data.get(key, default_value()) for key, default_value in get_default_values().items()} 26 | 27 | 28 | def get_default_values(): 29 | """ 30 | Returns a dictionary of default values for the environment variables. 31 | """ 32 | logger.debug("Getting default values for environment variables") 33 | return { 34 | 'TMDB_API_KEY': lambda: '', 35 | 'JELLYFIN_API_URL': lambda: '', 36 | 'JELLYFIN_TOKEN': lambda: '', 37 | 'SEER_API_URL': lambda: '', 38 | 'SEER_TOKEN': lambda: '', 39 | 'SEER_USER_NAME': lambda: None, 40 | 'SEER_USER_PSW': lambda: None, 41 | 'SEER_SESSION_TOKEN': lambda: None, 42 | 'MAX_SIMILAR_MOVIE': lambda: '5', 43 | 'MAX_SIMILAR_TV': lambda: '2', 44 | 'CRON_TIMES': lambda: '0 0 * * *', 45 | 'MAX_CONTENT_CHECKS': lambda: '10', 46 | 'SEARCH_SIZE': lambda: '20', 47 | 'JELLYFIN_LIBRARIES': lambda: [], 48 | 'PLEX_TOKEN': lambda: '', 49 | 'PLEX_API_URL': lambda: '', 50 | 'PLEX_LIBRARIES': lambda: [], 51 | 'SELECTED_SERVICE': lambda: '', 52 | 'FILTER_TMDB_THRESHOLD': lambda: None, 53 | 'FILTER_TMDB_MIN_VOTES': lambda: None, 54 | 'FILTER_GENRES_EXCLUDE': lambda: [], 55 | 'HONOR_JELLYSEER_DISCOVERY': lambda: False, 56 | 'FILTER_RELEASE_YEAR': lambda: None, 57 | 'FILTER_INCLUDE_NO_RATING': lambda: True, 58 | 'FILTER_LANGUAGE': lambda: None, 59 | 'FILTER_NUM_SEASONS': lambda: None, 60 | 'SELECTED_USERS': lambda: [], 61 | 'FILTER_STREAMING_SERVICES': lambda: [], 62 | 'FILTER_REGION_PROVIDER': lambda: None, 63 | 'SUBPATH': lambda: None, 64 | 'DB_TYPE': lambda: 'sqlite', 65 | 'DB_HOST': lambda: None, 66 | 'DB_PORT': lambda: None, 67 | 'DB_USER': lambda: None, 68 | 'DB_PASSWORD': lambda: None, 69 | 'DB_NAME': lambda: None, 70 | } 71 | 72 | def get_config_values(): 73 | """ 74 | Executes the lambdas and returns the actual values for JSON serialization. 75 | """ 76 | logger.debug("Resolving default values for configuration") 77 | default_values = get_default_values() 78 | resolved_values = {key: value() if callable(value) else value for key, value in default_values.items()} 79 | logger.debug(f"Resolved configuration values: {resolved_values}") 80 | return resolved_values 81 | 82 | def save_env_vars(config_data): 83 | """ 84 | Save environment variables from the web interface to the config.yaml file. 85 | Also validates cron times and updates them if needed. 86 | """ 87 | logger.debug("Saving environment variables to config.yaml") 88 | cron_times = config_data.get('CRON_TIMES', '0 0 * * *') 89 | 90 | if not croniter.is_valid(cron_times): 91 | logger.error("Invalid cron time provided.") 92 | raise ValueError("Invalid cron time provided.") 93 | 94 | try: 95 | # Prepare environment variables to be saved 96 | env_vars = { 97 | key: value for key, value in { 98 | key: config_data.get(key, default_value()) for key, default_value in get_default_values().items() 99 | }.items() if value not in [None, ''] 100 | } 101 | 102 | # Create config.yaml file if it does not exist 103 | if not os.path.exists(CONFIG_PATH): 104 | os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) 105 | logger.info(f'Creating new file for config at {CONFIG_PATH}') 106 | open(CONFIG_PATH, 'w').close() # Create an empty file 107 | 108 | # Write environment variables to the config.yaml file 109 | with open(CONFIG_PATH, 'w', encoding='utf-8') as f: 110 | yaml.safe_dump(env_vars, f) 111 | logger.debug(f"Environment variables saved: {env_vars}") 112 | 113 | # Reload environment variables after saving 114 | load_env_vars() 115 | 116 | # Update the cron job 117 | start_cron_job(env_vars) 118 | 119 | except Exception as e: 120 | logger.error(f"Error saving environment variables: {e}") 121 | raise 122 | 123 | 124 | def clear_env_vars(): 125 | """ 126 | Remove environment variables from memory and delete the config.yaml file if it exists. 127 | """ 128 | logger.debug("Clearing environment variables and deleting config.yaml if it exists") 129 | # Delete the config.yaml file if it exists 130 | if os.path.exists(CONFIG_PATH): 131 | try: 132 | os.remove(CONFIG_PATH) 133 | logger.info("Configuration cleared successfully.") 134 | except OSError as e: 135 | logger.error(f"Error deleting {CONFIG_PATH}: {e}") 136 | 137 | def save_session_token(token): 138 | """Save session token of Seer client.""" 139 | logger.debug(f"Saving session token: {token}") 140 | with open(CONFIG_PATH, 'r+', encoding='utf-8') as file: 141 | config_data = yaml.safe_load(file) or {} 142 | config_data['SEER_SESSION_TOKEN'] = token 143 | file.seek(0) 144 | yaml.dump(config_data, file) 145 | file.truncate() 146 | logger.debug("Session token saved successfully") 147 | -------------------------------------------------------------------------------- /api_service/config/cron_jobs.py: -------------------------------------------------------------------------------- 1 | from apscheduler.schedulers.background import BackgroundScheduler 2 | from api_service.config.logger_manager import LoggerManager 3 | import requests 4 | 5 | # Logging configuration 6 | logger = LoggerManager().get_logger("CronJobs") 7 | 8 | # Function to be executed periodically 9 | def force_run_job(): 10 | try: 11 | logger.info('Cron job started') 12 | requests.post('http://localhost:5000/api/automation/force_run') 13 | logger.info('Cron job executed successfully') 14 | except Exception as e: 15 | logger.error(f'Error executing cron job: {e}') 16 | 17 | # Function to start the cron job 18 | def start_cron_job(env_vars, job_id='auto_content_fetcher'): 19 | logger.debug('Starting cron job setup') 20 | scheduler = BackgroundScheduler() 21 | 22 | # Remove old jobs if exist 23 | existing_job = scheduler.get_job(job_id) 24 | if existing_job: 25 | scheduler.remove_job(job_id) 26 | logger.info(f"Removed old job with ID: {job_id}") 27 | 28 | cron_expression = env_vars.get('CRON_TIMES', '0 0 * * *') # Use the value of CRON_TIME from the environment variable 29 | logger.debug(f'Cron expression from env_vars: {cron_expression}') 30 | 31 | # Add the job using the dynamic cron expression 32 | cron_params = parse_cron_expression(cron_expression) 33 | logger.debug(f'Parsed cron parameters: {cron_params}') 34 | scheduler.add_job(force_run_job, 'cron', id=job_id, **cron_params) 35 | 36 | logger.info(f"Cron job '{job_id}' set with expression: {cron_expression}") 37 | scheduler.start() 38 | logger.debug('Scheduler started') 39 | 40 | def parse_cron_expression(cron_expression): 41 | """ 42 | Function to decode the cron expression. 43 | Returns a dictionary compatible with APScheduler. 44 | """ 45 | logger.debug(f'Parsing cron expression: {cron_expression}') 46 | cron_parts = cron_expression.split() 47 | 48 | # Return the dictionary for APScheduler 49 | cron_params = { 50 | 'minute': cron_parts[0], 51 | 'hour': cron_parts[1], 52 | 'day': cron_parts[2], 53 | 'month': cron_parts[3], 54 | 'day_of_week': cron_parts[4], 55 | } 56 | logger.debug(f'Cron expression parsed to: {cron_params}') 57 | return cron_params 58 | -------------------------------------------------------------------------------- /api_service/config/logger_manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | LoggerManager Module 3 | 4 | This module provides a centralized way to configure and manage loggers across the application. 5 | 6 | Classes: 7 | - LoggerManager: Configures and returns loggers for use in different parts of the application. 8 | 9 | Example: 10 | logger = LoggerManager.get_logger(__name__) 11 | logger.info("This is an informational message.") 12 | """ 13 | 14 | import os 15 | import logging 16 | import sys 17 | from concurrent_log_handler import ConcurrentRotatingFileHandler 18 | 19 | class LoggerManager: 20 | """ 21 | LoggerManager is responsible for configuring and managing loggers throughout the application. 22 | It provides a centralized way to set up logging with custom levels, formats, and handlers. 23 | """ 24 | 25 | @staticmethod 26 | def get_logger(name: str, max_bytes=5 * 1024 * 1024, backup_count=5, log_file=None): 27 | """ 28 | Returns a logger with the specified name and log level. 29 | 30 | :param name: The name of the logger (usually the module name). 31 | :param level: The logging level (e.g., logging.INFO, logging.DEBUG). 32 | :param log_file: The path to the file where logs will be saved. 33 | :param max_bytes: The maximum file size (in bytes) before rotating. 34 | :param backup_count: The number of backup files to keep. 35 | :return: Configured logger instance. 36 | """ 37 | 38 | if log_file is None: 39 | base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../config/config_files')) 40 | os.makedirs(base_dir, exist_ok=True) # Ensure directory exists 41 | log_file = os.path.join(base_dir, 'app.log') 42 | 43 | logger = logging.getLogger(name) 44 | log_level = os.getenv('LOG_LEVEL', 'info').upper() 45 | logger.setLevel(log_level) 46 | 47 | # Check if the logger already has handlers to avoid duplicate handlers 48 | if not logger.handlers: 49 | # Create a console handler to send logs to stdout 50 | console_handler = logging.StreamHandler(sys.stdout) 51 | console_handler.setLevel(log_level) 52 | 53 | # Create a file handler to save logs to a file 54 | file_handler = ConcurrentRotatingFileHandler(log_file, maxBytes=max_bytes, backupCount=backup_count, encoding='utf-8') 55 | file_handler.setLevel(log_level) 56 | 57 | # Create a logging format 58 | formatter = logging.Formatter('%(asctime)s - %(name)-20s - %(levelname)-5s - %(message)s') 59 | console_handler.setFormatter(formatter) 60 | file_handler.setFormatter(formatter) 61 | 62 | # Add both handlers to the logger 63 | logger.addHandler(console_handler) 64 | logger.addHandler(file_handler) 65 | 66 | return logger 67 | -------------------------------------------------------------------------------- /api_service/conftest.py: -------------------------------------------------------------------------------- 1 | # For Pytest, do not remove this file. It is required to run tests. 2 | -------------------------------------------------------------------------------- /api_service/exceptions/database_exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class DatabaseError(Exception): 3 | """Base class for all database-related exceptions.""" 4 | def __init__(self, db_type, error): 5 | self.db_type = db_type 6 | self.error = error 7 | super().__init__(self._format_error()) 8 | 9 | def _format_error(self): 10 | if self.db_type == 'sqlite': 11 | return f"SQLite error: {str(self.error)}" 12 | elif self.db_type == 'postgres': 13 | return f"PostgreSQL error: {str(self.error)}" 14 | elif self.db_type in ['mysql', 'mariadb']: 15 | return f"MySQL error: {str(self.error)}" 16 | else: 17 | return f"Unknown DB error: {str(self.error)}" -------------------------------------------------------------------------------- /api_service/handler/jellyfin_handler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from api_service.services.jellyfin.jellyfin_client import JellyfinClient 4 | from api_service.services.jellyseer.seer_client import SeerClient 5 | from api_service.services.tmdb.tmdb_client import TMDbClient 6 | 7 | class JellyfinHandler: 8 | def __init__(self, jellyfin_client:JellyfinClient, jellyseer_client:SeerClient, tmdb_client:TMDbClient, logger, max_similar_movie, max_similar_tv, selected_users): 9 | """ 10 | Initialize JellyfinHandler with clients and parameters. 11 | :param jellyfin_client: Jellyfin API client 12 | :param jellyseer_client: Jellyseer API client 13 | :param tmdb_client: TMDb API client 14 | :param logger: Logger instance 15 | :param max_similar_movie: Max number of similar movies to request 16 | :param max_similar_tv: Max number of similar TV shows to request 17 | """ 18 | self.jellyfin_client = jellyfin_client 19 | self.jellyseer_client = jellyseer_client 20 | self.tmdb_client = tmdb_client 21 | self.logger = logger 22 | self.max_similar_movie = max_similar_movie 23 | self.max_similar_tv = max_similar_tv 24 | self.processed_series = set() 25 | self.request_count = 0 26 | self.existing_content = jellyfin_client.existing_content 27 | self.selected_users = selected_users 28 | 29 | async def process_recent_items(self): 30 | """Process recently watched items for all Jellyfin users.""" 31 | self.logger.debug("Starting process_recent_items") 32 | users = self.selected_users if len(self.selected_users) > 0 else await self.jellyfin_client.get_all_users() 33 | self.logger.debug(f"Users to process: {users}") 34 | tasks = [self.process_user_recent_items(user) for user in users] 35 | await asyncio.gather(*tasks) 36 | self.logger.info(f"Total media requested: {self.request_count}") 37 | 38 | async def process_user_recent_items(self, user): 39 | """Process recently watched items for a specific Jellyfin user.""" 40 | self.logger.info(f"Fetching content for user: {user['name']}") 41 | recent_items_by_library = await self.jellyfin_client.get_recent_items(user) 42 | self.logger.debug(f"Recent items for user {user['name']}: {recent_items_by_library}") 43 | 44 | if recent_items_by_library: 45 | tasks = [] 46 | for library_name, items in recent_items_by_library.items(): 47 | self.logger.debug(f"Processing library: {library_name} with items: {items}") 48 | tasks.extend([self.process_item(user, item) for item in items]) 49 | await asyncio.gather(*tasks) 50 | 51 | async def process_item(self, user, item): 52 | """Process an individual item (movie or TV show episode).""" 53 | self.logger.debug(f"Processing item: {item}") 54 | item_type = item['Type'].lower() 55 | if item_type == 'movie' and self.max_similar_movie > 0: 56 | await self.process_movie(user, item['Id']) 57 | elif item_type == 'episode' and self.max_similar_tv > 0: 58 | await self.process_episode(user, item) 59 | 60 | async def process_movie(self, user, item_id): 61 | """Find similar movies via TMDb and request them via Jellyseer.""" 62 | self.logger.debug(f"Processing movie with ID: {item_id}") 63 | source_tmbd_id = await self.jellyfin_client.get_item_provider_id(user['id'], item_id) 64 | self.logger.debug(f"TMDb ID for movie: {source_tmbd_id}") 65 | source_tmdb_obj = await self.tmdb_client.get_metadata(source_tmbd_id, 'movie') 66 | if source_tmbd_id: 67 | similar_movies = await self.tmdb_client.find_similar_movies(source_tmbd_id) 68 | self.logger.debug(f"Similar movies found: {similar_movies}") 69 | await self.request_similar_media(similar_movies, 'movie', self.max_similar_movie, source_tmdb_obj, user) 70 | 71 | async def process_episode(self, user, item): 72 | """Process a TV show episode by finding similar TV shows via TMDb.""" 73 | self.logger.debug(f"Processing episode: {item}") 74 | series_id = item.get('SeriesId') 75 | if series_id and series_id not in self.processed_series: 76 | self.processed_series.add(series_id) 77 | source_tmbd_id = await self.jellyfin_client.get_item_provider_id(user['id'], series_id, provider='Tmdb') 78 | self.logger.debug(f"TMDb ID for series: {source_tmbd_id}") 79 | source_tmdb_obj = await self.tmdb_client.get_metadata(source_tmbd_id, 'tv') 80 | if source_tmbd_id: 81 | similar_tvshows = await self.tmdb_client.find_similar_tvshows(source_tmbd_id) 82 | self.logger.debug(f"Similar TV shows found: {similar_tvshows}") 83 | await self.request_similar_media(similar_tvshows, 'tv', self.max_similar_tv, source_tmdb_obj, user) 84 | 85 | async def request_similar_media(self, media_ids, media_type, max_items, source_tmdb_obj, user): 86 | """Request similar media (movie/TV show) via Jellyseer.""" 87 | self.logger.debug(f"Requesting {max_items} similar media") 88 | if not media_ids: 89 | self.logger.info("No media IDs provided for similar media request.") 90 | return 91 | 92 | tasks = [] 93 | for media in media_ids[:max_items]: 94 | media_id = media['id'] 95 | media_title = media['title'] 96 | 97 | self.logger.debug(f"Processing similar media: '{media_title}' with ID: '{media_id}'") 98 | 99 | # Check if already downloaded, requested, or in an excluded streaming service 100 | already_requested = await self.jellyseer_client.check_already_requested(media_id, media_type) 101 | self.logger.debug(f"Already requested check for {media_title}: {already_requested}") 102 | if already_requested: 103 | self.logger.info(f"Skipping [{media_type}, {media_title}]: already requested.") 104 | continue 105 | 106 | already_downloaded = await self.jellyseer_client.check_already_downloaded(media_id, media_type, self.existing_content) 107 | self.logger.debug(f"Already downloaded check for {media_title}: {already_downloaded}") 108 | if already_downloaded: 109 | self.logger.info(f"Skipping [{media_type}, {media_title}]: already downloaded.") 110 | continue 111 | 112 | in_excluded_streaming_service, provider = await self.tmdb_client.get_watch_providers(source_tmdb_obj['id'], media_type) 113 | self.logger.debug(f"Excluded streaming service check for {media_title}: {in_excluded_streaming_service}, {provider}") 114 | if in_excluded_streaming_service: 115 | self.logger.info(f"Skipping [{media_type}, {media_title}]: excluded by streaming service: {provider}") 116 | continue 117 | 118 | # Add to tasks if it passes all checks 119 | tasks.append(self._request_media_and_log(media_type, media, source_tmdb_obj, user)) 120 | 121 | await asyncio.gather(*tasks) 122 | 123 | async def _request_media_and_log(self, media_type, media, source_tmdb_obj, user): 124 | """Helper method to request media and log the result.""" 125 | self.logger.debug(f"Requesting media: {media}") 126 | if await self.jellyseer_client.request_media(media_type=media_type, media=media, source=source_tmdb_obj, user=user): 127 | self.request_count += 1 128 | self.logger.info(f"Requested {media_type}: {media['title']}") 129 | -------------------------------------------------------------------------------- /api_service/handler/plex_handler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from api_service.services.jellyseer.seer_client import SeerClient 4 | from api_service.services.plex.plex_client import PlexClient 5 | from api_service.services.tmdb.tmdb_client import TMDbClient 6 | 7 | class PlexHandler: 8 | def __init__(self, plex_client: PlexClient, jellyseer_client: SeerClient, tmdb_client: TMDbClient, logger, max_similar_movie, max_similar_tv): 9 | """ 10 | Initialize PlexHandler with clients and parameters. 11 | :param plex_client: Plex API client 12 | :param jellyseer_client: Jellyseer API client 13 | :param tmdb_client: TMDb API client 14 | :param logger: Logger instance 15 | :param max_similar_movie: Max number of similar movies to request 16 | :param max_similar_tv: Max number of similar TV shows to request 17 | """ 18 | self.plex_client = plex_client 19 | self.jellyseer_client = jellyseer_client 20 | self.tmdb_client = tmdb_client 21 | self.logger = logger 22 | self.max_similar_movie = max_similar_movie 23 | self.max_similar_tv = max_similar_tv 24 | self.request_count = 0 25 | self.existing_content = plex_client.existing_content 26 | 27 | async def process_recent_items(self): 28 | """Process recently watched items for Plex (without user context).""" 29 | self.logger.info("Fetching recently watched content from Plex") 30 | recent_items_response = await self.plex_client.get_recent_items() 31 | 32 | if isinstance(recent_items_response, list): 33 | tasks = [] 34 | for response_item in recent_items_response: 35 | title = response_item.get('title', response_item.get('grandparentTitle')) 36 | self.logger.info(f"Processing item: {title}") 37 | tasks.append(self.process_item(response_item, title)) # No user context needed for Plex 38 | 39 | if tasks: 40 | await asyncio.gather(*tasks) 41 | self.logger.info(f"Total media requested: {self.request_count}") 42 | else: 43 | self.logger.warning("No recent items found in Plex response") 44 | else: 45 | self.logger.warning("Unexpected response format: expected a list") 46 | 47 | async def process_item(self, item, title): 48 | """Process an individual item (movie or TV show episode).""" 49 | self.logger.debug(f"Processing item: {item}") 50 | 51 | item_type = item['type'].lower() 52 | 53 | if (item_type == 'movie' and self.max_similar_movie > 0) or (item_type == 'episode' and self.max_similar_tv > 0): 54 | try: 55 | key = self.extract_rating_key(item, item_type) 56 | self.logger.debug(f"Extracted key: {key} for item type: {item_type}") 57 | if key: 58 | if item_type == 'movie': 59 | await self.process_movie(key, title) 60 | elif item_type == 'episode': 61 | await self.process_episode(key, title) 62 | else: 63 | raise ValueError(f"Missing key for {item_type} '{title}'. Cannot process this item. Skipping.") 64 | except Exception as e: 65 | self.logger.warning(f"Error while processing item: {str(e)}") 66 | 67 | def extract_rating_key(self, item, item_type): 68 | """Extract the appropriate key depending on the item type.""" 69 | key = item.get('key') if item_type == 'movie' else item.get('grandparentKey') if item_type == 'episode' else None 70 | self.logger.debug(f"Extracted rating key: {key} for item type: {item_type}") 71 | return key if key else None 72 | 73 | async def process_movie(self, movie_key, title): 74 | """Find similar movies via TMDb and request them via Jellyseer.""" 75 | self.logger.debug(f"Processing movie with key: {movie_key} - {title}") 76 | source_tmbd_id = await self.plex_client.get_metadata_provider_id(movie_key) 77 | self.logger.debug(f"TMDb ID retrieved: {source_tmbd_id}") 78 | source_tmdb_obj = await self.tmdb_client.get_metadata(source_tmbd_id, 'movie') 79 | self.logger.info(f"TMDb metadata: {source_tmdb_obj}") 80 | 81 | if source_tmbd_id: 82 | similar_movies = await self.tmdb_client.find_similar_movies(source_tmbd_id) 83 | self.logger.debug(f"Found similar movies: {similar_movies}") 84 | await self.request_similar_media(similar_movies, 'movie', self.max_similar_movie, source_tmdb_obj) 85 | else: 86 | self.logger.warning(f"Error while processing item: 'tmdb_id' not found for movie '{title}'. Skipping.") 87 | 88 | async def process_episode(self, show_key, title): 89 | """Process a TV show episode by finding similar TV shows via TMDb.""" 90 | self.logger.debug(f"Processing episode with show key: {show_key} - {title}") 91 | if show_key: 92 | source_tmbd_id = await self.plex_client.get_metadata_provider_id(show_key) 93 | self.logger.debug(f"TMDb ID retrieved: {source_tmbd_id}") 94 | source_tmdb_obj = await self.tmdb_client.get_metadata(source_tmbd_id, 'tv') 95 | self.logger.debug(f"TMDb metadata: {source_tmdb_obj}") 96 | 97 | if source_tmbd_id: 98 | similar_tvshows = await self.tmdb_client.find_similar_tvshows(source_tmbd_id) 99 | self.logger.debug(f"Found {len(similar_tvshows)} similar TV shows") 100 | await self.request_similar_media(similar_tvshows, 'tv', self.max_similar_tv, source_tmdb_obj) 101 | else: 102 | self.logger.warning(f"Error while processing item: 'tmdb_id' not found for tv show '{title}'. Skipping.") 103 | 104 | async def request_similar_media(self, media_ids, media_type, max_items, source_tmdb_obj): 105 | """Request similar media (movie/TV show) via Overseer.""" 106 | self.logger.debug(f"Requesting {max_items} similar media") 107 | if not media_ids: 108 | self.logger.info("No media IDs provided for similar media request.") 109 | return 110 | 111 | tasks = [] 112 | for media in media_ids[:max_items]: 113 | media_id = media['id'] 114 | media_title = media['title'] 115 | self.logger.debug(f"Processing similar media: '{media_title}' with ID: '{media_id}'") 116 | 117 | # Check if already downloaded, requested, or in an excluded streaming service 118 | already_requested = await self.jellyseer_client.check_already_requested(media_id, media_type) 119 | self.logger.debug(f"Already requested: {already_requested}") 120 | if already_requested: 121 | self.logger.info(f"Skipping [{media_type}, {media_title}]: already requested.") 122 | continue 123 | 124 | already_downloaded = await self.jellyseer_client.check_already_downloaded(media_id, media_type, self.existing_content) 125 | self.logger.debug(f"Already downloaded: {already_downloaded}") 126 | if already_downloaded: 127 | self.logger.info(f"Skipping [{media_type}, {media_title}]: already downloaded.") 128 | continue 129 | 130 | in_excluded_streaming_service, provider = await self.tmdb_client.get_watch_providers(source_tmdb_obj['id'], media_type) 131 | self.logger.debug(f"In excluded streaming service: {in_excluded_streaming_service}, Provider: {provider}") 132 | if in_excluded_streaming_service: 133 | self.logger.info(f"Skipping [{media_type}, {media_title}]: excluded by streaming service: {provider}") 134 | continue 135 | 136 | # Add to tasks if it passes all checks 137 | tasks.append(self._request_media_and_log(media_type, media, source_tmdb_obj)) 138 | 139 | await asyncio.gather(*tasks) 140 | 141 | async def _request_media_and_log(self, media_type, media, source_tmdb_obj): 142 | """Helper method to request media and log the result.""" 143 | self.logger.debug(f"Requesting media: {media} of type: {media_type}") 144 | if await self.jellyseer_client.request_media(media_type, media, source_tmdb_obj): 145 | self.request_count += 1 146 | self.logger.info(f"Requested {media_type}: {media['title']}") 147 | -------------------------------------------------------------------------------- /api_service/requirements.dev.txt: -------------------------------------------------------------------------------- 1 | pytest -------------------------------------------------------------------------------- /api_service/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/api_service/requirements.txt -------------------------------------------------------------------------------- /api_service/services/jellyfin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/api_service/services/jellyfin/__init__.py -------------------------------------------------------------------------------- /api_service/services/jellyfin/jellyfin_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module provides the JellyfinClient class for interacting with the Jellyfin API. 3 | The client can retrieve users, recent items, and provider IDs for media content. 4 | 5 | Classes: 6 | - JellyfinClient: A class that handles communication with the Jellyfin API. 7 | """ 8 | import aiohttp 9 | from api_service.config.logger_manager import LoggerManager 10 | 11 | # Constants 12 | REQUEST_TIMEOUT = 10 # Timeout in seconds for HTTP requests 13 | 14 | 15 | class JellyfinClient: 16 | """ 17 | A client to interact with the Jellyfin API, allowing the retrieval of users, recent items, 18 | and media provider IDs. 19 | """ 20 | 21 | def __init__(self, api_url, token, max_content=10, library_ids=None): 22 | """ 23 | Initializes the JellyfinClient with the provided API URL and token. 24 | :param api_url: The base URL for the Jellyfin API. 25 | :param token: The authentication token for Jellyfin. 26 | """ 27 | self.logger = LoggerManager.get_logger(self.__class__.__name__) 28 | self.max_content_fetch = max_content 29 | self.api_url = api_url 30 | self.libraries = library_ids 31 | self.headers = {"X-Emby-Token": token} 32 | self.existing_content = {} 33 | 34 | async def init_existing_content(self): 35 | self.logger.info('Initializing existing content.') 36 | self.existing_content = await self.get_all_library_items() 37 | self.logger.debug(f'Existing content initialized: {self.existing_content}') 38 | 39 | async def get_all_users(self): 40 | """ 41 | Retrieves a list of all users from the Jellyfin server asynchronously. 42 | :return: A list of users in JSON format if successful, otherwise an empty list. 43 | """ 44 | url = f"{self.api_url}/Users" 45 | self.logger.debug(f'Requesting all users from {url}') 46 | try: 47 | async with aiohttp.ClientSession() as session: 48 | async with session.get(url, headers=self.headers, timeout=REQUEST_TIMEOUT) as response: 49 | if response.status == 200: 50 | data = await response.json() 51 | self.logger.debug(f'Users retrieved: {data}') 52 | return [{"id": user["Id"], "name": user["Name"], "policy": user["Policy"]} for user in data] 53 | self.logger.error("Failed to retrieve users: %d", response.status) 54 | except aiohttp.ClientError as e: 55 | self.logger.error("An error occurred while retrieving users: %s", str(e)) 56 | 57 | return [] 58 | 59 | async def get_all_library_items(self): 60 | """ 61 | Retrieves all items from the specified libraries or all libraries if no specific IDs are provided. 62 | :return: A dictionary of items organized by library name. 63 | """ 64 | results_by_library = {} 65 | users = await self.get_all_users() 66 | admin_user = next((user for user in users if user.get('policy', {}).get('IsAdministrator')), None) 67 | self.logger.debug(f'Admin user: {admin_user}') 68 | libraries = self.libraries if self.libraries else await self.get_libraries() 69 | self.logger.debug(f'Libraries to fetch items from: {libraries}') 70 | 71 | if not libraries: 72 | self.logger.error("No libraries found.") 73 | return None 74 | 75 | for library in libraries: 76 | library_id = library.get('id') 77 | library_name = library.get('name') 78 | library_type = '' 79 | 80 | params = { 81 | "Recursive": "true", 82 | "IncludeItemTypes": "Movie,Series", 83 | "ParentID": library_id 84 | } 85 | self.logger.debug(f'Requesting items for library {library_name} with params: {params}') 86 | 87 | try: 88 | async with aiohttp.ClientSession() as session: 89 | async with session.get(f"{self.api_url}/Items", headers=self.headers, params=params, timeout=REQUEST_TIMEOUT) as response: 90 | if response.status == 200: 91 | library_items = await response.json() 92 | items = library_items.get('Items', []) 93 | self.logger.debug(f'Items retrieved for library {library_name}: {items}') 94 | 95 | for item in items: 96 | item_id = item.get('Id') 97 | library_type = 'tv' if item.get('Type') == 'Series' else 'movie' 98 | if item_id: 99 | tmdb_id = await self.get_item_provider_id(admin_user['id'], item_id, provider='Tmdb') 100 | item['tmdb_id'] = tmdb_id 101 | self.logger.debug(f'Item {item_id} TMDb ID: {tmdb_id}') 102 | 103 | results_by_library[library_type] = items 104 | self.logger.info(f"Retrieved {len(items)} items in {library_name}") 105 | else: 106 | self.logger.error( 107 | "Failed to get items for library %s: %d", library_name, response.status) 108 | except TypeError as e: 109 | self.logger.error(f"TypeError: {e}. Params: {params}, Timeout: {REQUEST_TIMEOUT}") 110 | except aiohttp.ClientError as e: 111 | self.logger.error( 112 | "An error occurred while retrieving items for library %s: %s", library_name, str(e)) 113 | except Exception as e: 114 | self.logger.error(e) 115 | 116 | return results_by_library if results_by_library else None 117 | 118 | 119 | async def get_recent_items(self, user): 120 | """ 121 | Retrieves a list of recently played items for a given user from specific libraries asynchronously. 122 | :param user_id: The ID of the user whose recent items are to be retrieved. 123 | :return: A combined list of recent items from all specified libraries, organized by library. 124 | """ 125 | results_by_library = {} 126 | seen_series = set() # Track seen series to avoid duplicates 127 | 128 | url = f"{self.api_url}/Items" 129 | self.logger.debug(f'Requesting recent items for user {user["name"]} from {url}') 130 | 131 | for library in self.libraries: 132 | library_id = library.get('id') 133 | 134 | params = { 135 | "SortBy": "DatePlayed", 136 | "SortOrder": "Descending", 137 | 'isPlayed': "true", 138 | "Recursive": "true", 139 | "IncludeItemTypes": "Movie,Episode", 140 | "userId": user['id'], 141 | "Limit": self.max_content_fetch, 142 | "ParentID": library_id 143 | } 144 | self.logger.debug(f'Requesting recent items for library {library.get("name")} with params: {params}') 145 | 146 | try: 147 | async with aiohttp.ClientSession() as session: 148 | async with session.get(url, headers=self.headers, params=params, timeout=REQUEST_TIMEOUT) as response: 149 | if response.status == 200: 150 | library_items = await response.json() 151 | items = library_items.get('Items', []) 152 | self.logger.debug(f'Recent items retrieved for library {library.get("name")}: {items}') 153 | 154 | filtered_items = [] # Store the filtered items for this library 155 | 156 | for item in items: 157 | # Check if we've reached the max content fetch limit 158 | if len(filtered_items) >= int(self.max_content_fetch): 159 | break 160 | 161 | # If the item is an episode, check for its series 162 | if item['Type'] == 'Episode': 163 | series_title = item['SeriesName'] 164 | if series_title not in seen_series: 165 | seen_series.add(series_title) 166 | filtered_items.append(item) # Add the episode as part of the series 167 | else: 168 | filtered_items.append(item) # Add movies directly 169 | 170 | results_by_library[library.get('name')] = filtered_items # Add filtered items to the results by library 171 | self.logger.info(f"Retrieved {len(filtered_items)} watched items in {library.get('name')}. for user {user['name']}") 172 | 173 | else: 174 | self.logger.error( 175 | "Failed to get recent items for library %s (user %s): %d", library.get('name'), user['name'], response.status) 176 | except aiohttp.ClientError as e: 177 | self.logger.error( 178 | "An error occurred while retrieving recent items for library %s (user %s): %s", library.get('name'), user['name'], str(e)) 179 | except Exception as e: 180 | self.logger.error(e) 181 | 182 | return results_by_library if results_by_library else None 183 | 184 | 185 | async def get_libraries(self): 186 | """ 187 | Retrieves list of library asynchronously. 188 | """ 189 | self.logger.info("Searching Jellyfin libraries.") 190 | 191 | url = f"{self.api_url}/Library/VirtualFolders" 192 | self.logger.debug(f'Requesting libraries from {url}') 193 | 194 | try: 195 | async with aiohttp.ClientSession() as session: 196 | async with session.get( 197 | url, headers=self.headers, timeout=REQUEST_TIMEOUT) as response: 198 | if response.status == 200: 199 | libraries = await response.json() 200 | self.logger.debug(f'Libraries retrieved: {libraries}') 201 | return libraries 202 | self.logger.error( 203 | "Failed to get libraries %d", response.status) 204 | except aiohttp.ClientError as e: 205 | self.logger.error( 206 | "An error occurred while retrieving libraries: %s", str(e)) 207 | 208 | async def get_item_provider_id(self, user_id, item_id, provider='Tmdb'): 209 | """ 210 | Retrieves the provider ID (e.g., TMDb or TVDb) for a specific media item asynchronously. 211 | :param user_id: The ID of the user. 212 | :param item_id: The ID of the media item. 213 | :param provider: The provider ID to retrieve (default is 'Tmdb'). 214 | :return: The provider ID if found, otherwise None. 215 | """ 216 | url = f"{self.api_url}/Users/{user_id}/Items/{item_id}" 217 | self.logger.debug(f'Requesting provider ID for item {item_id} from {url}') 218 | try: 219 | async with aiohttp.ClientSession() as session: 220 | async with session.get(url, headers=self.headers, timeout=REQUEST_TIMEOUT) as response: 221 | if response.status == 200: 222 | item_data = await response.json() 223 | provider_id = item_data.get('ProviderIds', {}).get(provider) 224 | self.logger.debug(f'Provider ID for item {item_id}: {provider_id}') 225 | return provider_id 226 | 227 | self.logger.error("Failed to retrieve ID for item %s: %d", item_id, response.status) 228 | except aiohttp.ClientError as e: 229 | self.logger.error("An error occurred while retrieving ID for item %s: %s", item_id, str(e)) 230 | 231 | return None 232 | -------------------------------------------------------------------------------- /api_service/services/jellyseer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/api_service/services/jellyseer/__init__.py -------------------------------------------------------------------------------- /api_service/services/jellyseer/seer_client.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import asyncio 3 | from api_service.config.logger_manager import LoggerManager 4 | from api_service.db.database_manager import DatabaseManager 5 | 6 | # Constants for HTTP status codes and request timeout 7 | HTTP_OK = {200, 201, 202} 8 | REQUEST_TIMEOUT = 10 # Timeout in seconds for HTTP requests 9 | BATCH_SIZE = 20 # Number of requests fetched per batch 10 | 11 | class SeerClient: 12 | """ 13 | A client to interact with the Jellyseer API for handling media requests and authentication. 14 | """ 15 | 16 | def __init__(self, api_url, api_key, seer_user_name=None, seer_password=None, session_token=None, number_of_seasons="all"): 17 | """ 18 | Initializes the JellyseerClient with the API URL and logs in the user. 19 | """ 20 | self.logger = LoggerManager.get_logger(self.__class__.__name__) 21 | self.api_url = api_url 22 | self.api_key = api_key 23 | self.username = seer_user_name 24 | self.password = seer_password 25 | self.session_token = session_token 26 | self.is_logged_in = False 27 | self._login_lock = asyncio.Lock() 28 | self.number_of_seasons = number_of_seasons 29 | self.pending_requests = set() 30 | self.logger.debug("SeerClient initialized with API URL: %s", api_url) 31 | 32 | async def init(self): 33 | """ 34 | Asynchronous initialization method to fetch all requests. 35 | This is typically called after creating an instance of JellyseerClient 36 | to ensure that the requests cache is populated. 37 | """ 38 | self.logger.debug("Initializing SeerClient...") 39 | await self.fetch_all_requests() 40 | 41 | def _get_auth_headers(self, use_cookie): 42 | """Prepare headers and cookies based on `use_cookie` flag.""" 43 | headers = {'Content-Type': 'application/json', 'accept': 'application/json'} 44 | cookies = {} 45 | 46 | if use_cookie and self.session_token: 47 | cookies['connect.sid'] = self.session_token 48 | elif not use_cookie: 49 | headers['X-Api-Key'] = self.api_key 50 | 51 | return headers, cookies 52 | 53 | async def _make_request(self, method, endpoint, data=None, use_cookie=False, retries=3, delay=2): 54 | """Unified API request handling with retry logic and error handling.""" 55 | url = f"{self.api_url}/{endpoint}" 56 | headers, cookies = self._get_auth_headers(use_cookie) 57 | 58 | for attempt in range(retries): 59 | self.logger.debug("Attempt %d for request to %s", attempt + 1, url) 60 | async with aiohttp.ClientSession(headers=headers, cookies=cookies) as session: 61 | try: 62 | async with session.request(method, url, json=data, timeout=REQUEST_TIMEOUT) as response: 63 | self.logger.debug("Received response with status %d for request to %s", response.status, url) 64 | if response.status in HTTP_OK: 65 | return await response.json() 66 | elif response.status in (403, 404) and attempt < retries - 1: 67 | self.logger.debug("Retrying login due to status %d", response.status) 68 | await self.login() 69 | else: 70 | resp = await response.json() 71 | self.logger.error( 72 | f"Request to {url} failed with status: {response.status}, {resp['message'] if 'message' in resp else resp['error']}" 73 | ) 74 | except aiohttp.ClientError as e: 75 | self.logger.error(f"Client error during request to {url}: {e}") 76 | await asyncio.sleep(delay) 77 | return None 78 | 79 | async def login(self): 80 | """Authenticate with Jellyseer and obtain a session token.""" 81 | async with self._login_lock: 82 | if self.is_logged_in: 83 | self.logger.debug("Already logged in.") 84 | return 85 | 86 | login_url = f"{self.api_url}/api/v1/auth/local" 87 | self.logger.debug("Logging in to %s", login_url) 88 | async with aiohttp.ClientSession() as session: 89 | try: 90 | login_data = {"email": self.username, "password": self.password} 91 | async with session.post(login_url, json=login_data, timeout=REQUEST_TIMEOUT) as response: 92 | self.logger.debug("Login response status: %d", response.status) 93 | if response.status == 200 and 'connect.sid' in response.cookies: 94 | self.session_token = response.cookies['connect.sid'].value 95 | self.is_logged_in = True 96 | self.logger.info("Successfully logged in as %s", self.username) 97 | else: 98 | self.logger.error("Login failed: %d", response.status) 99 | except asyncio.TimeoutError: 100 | self.logger.error("Login request to %s timed out.", login_url) 101 | 102 | async def get_all_users(self, max_users=100): 103 | """Fetch all users from Jellyseer API, returning a list of user IDs, names, and local status.""" 104 | self.logger.debug("Fetching all users with max_users=%d", max_users) 105 | data = await self._make_request("GET", f"api/v1/user?take={max_users}") 106 | if data: 107 | self.logger.debug("Fetched users data: %s", data) 108 | return [ 109 | { 110 | 'id': user['id'], 111 | 'name': user.get('displayName', user.get('jellyfinUsername', 'Unknown User')), 112 | 'email': user.get('email'), 113 | 'isLocal': user.get('plexUsername') is None and user.get('jellyfinUsername') is None 114 | } 115 | for user in data.get('results', []) 116 | ] 117 | return [] 118 | 119 | async def fetch_all_requests(self): 120 | """Fetch all requests made in Jellyseer and save them to the database.""" 121 | self.logger.debug("Fetching all requests...") 122 | total_requests = await self.get_total_request() 123 | self.logger.debug("Total requests to fetch: %d", total_requests) 124 | tasks = [self._fetch_batch(skip) for skip in range(0, total_requests, BATCH_SIZE)] 125 | await asyncio.gather(*tasks) 126 | self.logger.info("Fetched all requests and saved to database.") 127 | 128 | async def _fetch_batch(self, skip): 129 | """Fetch a batch of requests and save them to the database.""" 130 | self.logger.debug("Fetching batch of requests starting at skip=%d", skip) 131 | try: 132 | data = await self._make_request("GET", f"api/v1/request?take={BATCH_SIZE}&skip={skip}") 133 | if data: 134 | requests = data.get('results', []) 135 | DatabaseManager().save_requests_batch(requests) 136 | except Exception as e: 137 | self.logger.error(f"Failed to fetch batch at skip {skip}: {e}") 138 | 139 | async def get_total_request(self): 140 | """Get total requests made in Jellyseer.""" 141 | self.logger.debug("Getting total request count...") 142 | data = await self._make_request("GET", "api/v1/request/count") 143 | total = data.get('total', 0) if data else 0 144 | self.logger.debug("Total requests count: %d", total) 145 | return total 146 | 147 | async def request_media(self, media_type, media, source=None, tvdb_id=None, user=None): 148 | """Request media and save it to the database if successful.""" 149 | 150 | # Avoid duplicate requests 151 | if (media_type, media['id']) in self.pending_requests: 152 | self.logger.debug("Skipping duplicate request for %s (ID: %s)", media_type, media['id']) 153 | return False 154 | 155 | self.pending_requests.add((media_type, media['id'])) 156 | self.logger.debug("Requesting media: %s, media_type: %s", media, media_type) 157 | data = {"mediaType": media_type, "mediaId": media['id']} 158 | 159 | if media_type == 'tv': 160 | data["tvdbId"] = media['id'] 161 | data["seasons"] = "all" if self.number_of_seasons == "all" else list(range(1, int(self.number_of_seasons) + 1)) 162 | 163 | response = await self._make_request("POST", "api/v1/request", data=data, use_cookie=bool(self.session_token)) 164 | if response and 'error' not in response: 165 | self.logger.debug("Media request successful: %s", response) 166 | databaseManager = DatabaseManager() 167 | databaseManager.save_user(user) 168 | databaseManager.save_request(media_type, media['id'], source['id'], user['id']) 169 | databaseManager.save_metadata(source, media_type) 170 | databaseManager.save_metadata(media, media_type) 171 | return True 172 | else: 173 | self.logger.error("Media request failed: %s", response) 174 | return False 175 | 176 | async def check_already_requested(self, tmdb_id, media_type): 177 | """Check if a media request is cached in the current cycle.""" 178 | self.logger.debug("Checking if media already requested: tmdb_id=%s, media_type=%s", tmdb_id, media_type) 179 | 180 | try: 181 | result = DatabaseManager().check_request_exists(media_type, tmdb_id) 182 | 183 | if not isinstance(result, bool): 184 | self.logger.warning("Unexpected return value from check_request_exists: %s", result) 185 | return False 186 | 187 | return result 188 | except Exception as e: 189 | self.logger.error("Error checking if media already requested: %s", e, exc_info=True) 190 | return False 191 | 192 | async def check_already_downloaded(self, tmdb_id, media_type, local_content={}): 193 | """Check if a media item has already been downloaded based on local content.""" 194 | self.logger.debug("Checking if media already downloaded: tmdb_id=%s, media_type=%s", tmdb_id, media_type) 195 | 196 | items = local_content.get(media_type, []) 197 | if not isinstance(items, list): 198 | self.logger.warning("Expected list for media_type '%s', but got %s", media_type, type(items)) 199 | return False 200 | 201 | for item in items: 202 | if not isinstance(item, dict): 203 | self.logger.warning("Skipping invalid item in local_content: %s", item) 204 | continue 205 | if 'tmdb_id' not in item: 206 | self.logger.warning("Skipping item without 'tmdb_id': %s", item) 207 | continue 208 | if item['tmdb_id'] == str(tmdb_id): 209 | return True 210 | 211 | return False 212 | 213 | async def get_metadata(self, media_id, media_type): 214 | """Retrieve metadata for a specific media item.""" 215 | self.logger.debug("Getting metadata for media_id=%s, media_type=%s", media_id, media_type) 216 | return DatabaseManager().get_metadata(media_id, media_type) -------------------------------------------------------------------------------- /api_service/services/plex/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/api_service/services/plex/__init__.py -------------------------------------------------------------------------------- /api_service/services/plex/plex_auth.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | class PlexAuth: 5 | def __init__(self, client_id): 6 | self.client_id = client_id 7 | self.base_url = 'https://plex.tv/api/v2' 8 | self.headers = { 9 | 'X-Plex-Product': 'SuggestArr', 10 | 'X-Plex-Client-Identifier': self.client_id, 11 | "Accept": 'application/json' 12 | } 13 | 14 | def get_authentication_pin(self): 15 | """Genera un nuovo pin di autenticazione.""" 16 | response = requests.post(f"{self.base_url}/pins?strong=true", headers=self.headers) 17 | data = response.json() 18 | auth_url = f"https://app.plex.tv/auth#?clientID={self.client_id}&code={data['code']}" 19 | return data['id'], auth_url 20 | 21 | def check_authentication(self, pin_id): 22 | """Verifica se l'utente ha completato l'autenticazione.""" 23 | response = requests.get(f"{self.base_url}/pins/{pin_id}", headers=self.headers) 24 | data = response.json() 25 | if 'authToken' in data: 26 | return data['authToken'] 27 | return None -------------------------------------------------------------------------------- /api_service/services/tmdb/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/api_service/services/tmdb/__init__.py -------------------------------------------------------------------------------- /api_service/tasks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/api_service/tasks/__init__.py -------------------------------------------------------------------------------- /api_service/tasks/tasks.py: -------------------------------------------------------------------------------- 1 | from concurrent.futures import ThreadPoolExecutor 2 | import asyncio 3 | 4 | from api_service.automate_process import ContentAutomation 5 | 6 | executor = ThreadPoolExecutor(max_workers=2) 7 | 8 | async def run_content_automation_task(): 9 | """ Runs the automation process as a background task using ThreadPoolExecutor. """ 10 | content_automation = await ContentAutomation.create() 11 | await content_automation.run() 12 | -------------------------------------------------------------------------------- /api_service/test/test.py: -------------------------------------------------------------------------------- 1 | # This file is for useful testing functions that are shared across multiple test files. 2 | 3 | 4 | def _verbose_dict_compare(dict1, dict2, assert_func): 5 | """Helper function to print out the differences between two dicts. This provides more 6 | useful error messages than a simple assertEqual.""" 7 | for item in dict1: 8 | assert_func(dict1[item], dict2[item]) 9 | 10 | for item in dict2: 11 | assert_func(dict1[item], dict2[item]) 12 | -------------------------------------------------------------------------------- /api_service/test/test_config.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from test import _verbose_dict_compare 4 | from api_service.config.config import load_env_vars, save_env_vars, get_default_values 5 | 6 | 7 | class TestConfig(unittest.TestCase): 8 | 9 | # This should be kept in sync with mock data for save/load config structure. 10 | config_data = { 11 | "CRON_TIMES": "0 4 * * *", 12 | "FILTER_LANGUAGE": [{"id": "en", "english_name": "English"}], 13 | "FILTER_GENRES_EXCLUDE": [{"id": 27, "name": "Horror"}, {"id": 10752, "name": "War"}], 14 | "FILTER_INCLUDE_NO_RATING": "false", 15 | "FILTER_RELEASE_YEAR": "2000", 16 | "FILTER_TMDB_MIN_VOTES": "50", 17 | "FILTER_TMDB_THRESHOLD": "75", 18 | "FILTER_NUM_SEASONS": 0, 19 | "HONOR_JELLYSEER_DISCOVERY": "false", 20 | "JELLYFIN_API_URL": "", 21 | "JELLYFIN_LIBRARIES": [], 22 | "JELLYFIN_TOKEN": "", 23 | "MAX_CONTENT_CHECKS": 10, 24 | "MAX_SIMILAR_MOVIE": 5, 25 | "MAX_SIMILAR_TV": 2, 26 | "PLEX_API_URL": "https://totally.legit.url.tld", 27 | "PLEX_LIBRARIES": ["1", "2"], 28 | "PLEX_TOKEN": "7h349fh349fj3", 29 | "SEARCH_SIZE": 20, 30 | "SEER_API_URL": "https://overseerr.totally.legit.url.tld", 31 | "SEER_SESSION_TOKEN": "s%3A1Db_7DWVJ7nU1R_KsGRQWFLxbisV2m4q.RTKKKBwhMWdMJ4VJNrAIngNFmztqnywP5TkctRYB%2B6M", 32 | "SEER_TOKEN": "", 33 | "SEER_USER_NAME": "someemail123@somedomain.com", 34 | "SEER_USER_PSW": "Y.M8d*HUkpds8PXCeMZM", 35 | "SELECTED_SERVICE": "plex", 36 | "TMDB_API_KEY": "123abc", 37 | "SELECTED_USERS": ["1", "2"], 38 | "FILTER_STREAMING_SERVICES": [{"provider_id": "8", "provider_name": "Netflix"}], 39 | "FILTER_REGION_PROVIDER": "US", 40 | "SUBPATH": "/suggestarr", 41 | "DB_TYPE": "sqlite", 42 | "DB_HOST": "localhost", 43 | "DB_PORT": "5432", 44 | "DB_USER": "postgres", 45 | "DB_PASSWORD": "password", 46 | "DB_NAME": "suggestarr", 47 | } 48 | 49 | def test_save_default_env_vars(self): 50 | """The default values should be able to be saved/loaded.""" 51 | default_config = {key: default_value() for key, default_value in get_default_values().items()} 52 | save_env_vars(default_config) 53 | loaded_config = load_env_vars() 54 | _verbose_dict_compare(default_config, loaded_config, self.assertEqual) 55 | 56 | def test_save_env_vars(self): 57 | """Confirms that the backend save and load functions retain the correct types, values, and 58 | structure.""" 59 | save_env_vars(self.config_data) 60 | loaded_config = load_env_vars() 61 | _verbose_dict_compare(self.config_data, loaded_config, self.assertEqual) 62 | -------------------------------------------------------------------------------- /api_service/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/api_service/utils/__init__.py -------------------------------------------------------------------------------- /api_service/utils/clients.py: -------------------------------------------------------------------------------- 1 | from api_service.services.jellyfin.jellyfin_client import JellyfinClient 2 | from api_service.services.jellyseer.seer_client import SeerClient 3 | from api_service.services.plex.plex_client import PlexClient 4 | 5 | def get_client(service_type, api_url, api_key, **kwargs): 6 | """ 7 | Return the appropriate client based on the service type. 8 | :param service_type: The type of service ('jellyfin', 'seer', 'plex') 9 | :param api_url: The API URL for the service 10 | :param api_key: The API key or token 11 | :param kwargs: Additional parameters like user credentials 12 | :return: Initialized client for the specified service 13 | """ 14 | if service_type == 'jellyfin': 15 | return JellyfinClient(api_url=api_url, token=api_key) 16 | elif service_type == 'seer': 17 | return SeerClient(api_url=api_url, api_key=api_key, **kwargs) 18 | elif service_type == 'plex': 19 | return PlexClient(api_url=api_url, token=api_key) 20 | else: 21 | raise ValueError(f"Unknown service type: {service_type}") 22 | -------------------------------------------------------------------------------- /api_service/utils/error_handlers.py: -------------------------------------------------------------------------------- 1 | # utils/error_handlers.py 2 | 3 | from functools import wraps 4 | from flask import jsonify 5 | 6 | def handle_api_errors(f): 7 | """ 8 | A decorator to handle errors for API routes. 9 | """ 10 | @wraps(f) 11 | def decorated_function(*args, **kwargs): 12 | try: 13 | return f(*args, **kwargs) 14 | except ValueError as ve: 15 | return jsonify({'message': str(ve), 'type': 'error'}), 400 16 | except FileNotFoundError as fnfe: 17 | return jsonify({'message': str(fnfe), 'type': 'error'}), 404 18 | except Exception as e: 19 | return jsonify({'message': f'Unexpected error: {str(e)}', 'type': 'error'}), 500 20 | return decorated_function 21 | 22 | def validate_required_fields(required_fields, data): 23 | """ 24 | Ensure that the required fields are present in the provided data. 25 | """ 26 | missing_fields = [field for field in required_fields if field not in data] 27 | if missing_fields: 28 | raise ValueError(f"Missing required fields: {', '.join(missing_fields)}") 29 | 30 | def success_response(message, data=None): 31 | """ 32 | Create a successful response message. 33 | """ 34 | response = {'message': message, 'type': 'success'} 35 | if data: 36 | response['data'] = data 37 | return jsonify(response), 200 38 | 39 | def error_response(message, status_code=400): 40 | """ 41 | Create an error response message. 42 | """ 43 | return jsonify({'message': message, 'type': 'error'}), status_code 44 | -------------------------------------------------------------------------------- /api_service/utils/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility functions for managing environment and worker processes. 3 | """ 4 | 5 | import os 6 | import subprocess 7 | 8 | from dotenv import load_dotenv 9 | 10 | from api_service.config.logger_manager import LoggerManager 11 | 12 | logger = LoggerManager.get_logger(__name__) 13 | 14 | class AppUtils: 15 | """ 16 | A utility class for application-level tasks such as environment loading 17 | and worker process identification. 18 | """ 19 | 20 | @staticmethod 21 | def is_last_worker(): 22 | """ 23 | Check if the current process is the last worker based on the highest PID. 24 | """ 25 | if os.name == 'nt': # Skip on Windows 26 | return True 27 | 28 | current_pid = os.getpid() 29 | try: 30 | # Run the ps command to list all process IDs, one per line. 31 | result = subprocess.run( 32 | ['ps', '-e', '-o', 'pid='], 33 | stdout=subprocess.PIPE, 34 | text=True, 35 | check=True 36 | ) 37 | # Parse the output and convert each PID to an integer. 38 | pids = [int(pid) for pid in result.stdout.strip().splitlines()] 39 | return current_pid == max(pids) 40 | except Exception as e: 41 | # Handle potential errors (for example, if the ps command fails) 42 | logger.error(f"Error obtaining process list: {e}") 43 | return False 44 | 45 | @staticmethod 46 | def load_environment(): 47 | """ 48 | Reload environment variables from the .env file. 49 | """ 50 | load_dotenv(override=True) 51 | logger.debug("Environment variables reloaded.") 52 | 53 | @staticmethod 54 | def print_welcome_message(): 55 | """ 56 | Log the welcome message. 57 | """ 58 | port = os.environ.get('SUGGESTARR_PORT', '5000') 59 | welcome_message = f""" 60 | 61 | ===================================================================================== 62 | | Welcome to the SuggestArr Application! | 63 | | Manage your settings through the web interface at: http://localhost:{port} | 64 | | Fill in the input fields with your data and let the cron job handle the rest! | 65 | | To run the automation process immediately, click the 'Force Run' button. | 66 | | The 'Force Run' button will appear only after you save your settings. | 67 | | To leave feedback visit: https://github.com/giuseppe99barchetta/SuggestArr | 68 | ===================================================================================== 69 | """ 70 | logger.info(welcome_message) 71 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # suggestarr-frontend 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | npm run lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /client/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "baseUrl": "./", 6 | "moduleResolution": "node", 7 | "paths": { 8 | "@/*": [ 9 | "src/*" 10 | ] 11 | }, 12 | "lib": [ 13 | "esnext", 14 | "dom", 15 | "dom.iterable", 16 | "scripthost" 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "suggestarr", 3 | "version": "v1.0.20", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build --skip-plugins @vue/cli-plugin-eslint", 8 | "lint": "vue-cli-service lint", 9 | "start": "vue-cli-service serve" 10 | }, 11 | "dependencies": { 12 | "@fortawesome/fontawesome-svg-core": "^6.6.0", 13 | "@fortawesome/free-brands-svg-icons": "^6.6.0", 14 | "@fortawesome/free-solid-svg-icons": "^6.6.0", 15 | "@fortawesome/vue-fontawesome": "^3.0.8", 16 | "axios": "^1.7.7", 17 | "core-js": "^3.8.3", 18 | "cron-parser": "^4.9.0", 19 | "vue": "^3.2.13", 20 | "vue-multiselect": "^3.1.0", 21 | "vue-router": "^4.0.13", 22 | "vue-toast-notification": "^3.1.3", 23 | "vue-toastification": "^2.0.0-rc.5" 24 | }, 25 | "devDependencies": { 26 | "@babel/core": "^7.12.16", 27 | "@babel/eslint-parser": "^7.12.16", 28 | "@vue/cli-plugin-babel": "^5.0.8", 29 | "@vue/cli-service": "^5.0.8", 30 | "@vue/compiler-sfc": "^3.5.13", 31 | "eslint": "^7.32.0", 32 | "eslint-plugin-vue": "^8.0.3", 33 | "vue-loader": "^17.4.2", 34 | "webpack": "^5.97.1" 35 | }, 36 | "eslintConfig": { 37 | "root": true, 38 | "env": { 39 | "node": true 40 | }, 41 | "extends": [ 42 | "plugin:vue/vue3-essential", 43 | "eslint:recommended" 44 | ], 45 | "parserOptions": { 46 | "parser": "@babel/eslint-parser" 47 | }, 48 | "rules": {} 49 | }, 50 | "browserslist": [ 51 | "> 1%", 52 | "last 2 versions", 53 | "not dead", 54 | "not ie 11" 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/images/default1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/client/public/images/default1.jpg -------------------------------------------------------------------------------- /client/public/images/default2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/client/public/images/default2.jpg -------------------------------------------------------------------------------- /client/public/images/default3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/client/public/images/default3.jpg -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | SuggestArr 9 | 10 | 11 | 12 | 13 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /client/src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | 28 | -------------------------------------------------------------------------------- /client/src/api/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | // Function to test the TMDB API key 4 | export const testTmdbApi = (apiKey) => { 5 | const tmdbApiUrl = `https://api.themoviedb.org/3/movie/550?api_key=${apiKey}`; // Movie ID 550 is Fight Club 6 | return axios.get(tmdbApiUrl); 7 | }; 8 | 9 | // Function to test Jellyfin configuration 10 | export const testJellyfinApi = (url, token) => { 11 | const jellyfinApiUrl = `${url}/Users`; // Endpoint to retrieve Jellyfin users 12 | return axios.get(jellyfinApiUrl, { 13 | headers: { 14 | 'X-Emby-Token': token // Send Jellyfin API token in the header 15 | } 16 | }); 17 | }; 18 | 19 | // Function to test the Jellyseer/Overseer configuration and fetch users 20 | export const testJellyseerApi = (url, token) => { 21 | return axios.post('/api/seer/get_users', { 22 | SEER_API_URL: url, 23 | SEER_TOKEN: token 24 | }); 25 | }; 26 | 27 | // Function to authenticate a user in Jellyseer/Overseer 28 | export const authenticateUser = (url, token, userName, password) => { 29 | return axios.post('/api/seer/login', { 30 | SEER_API_URL: url, 31 | SEER_TOKEN: token, 32 | SEER_USER_NAME: userName, 33 | SEER_PASSWORD: password 34 | }); 35 | }; 36 | 37 | // Function to fetch Jellyfin libraries 38 | export function fetchJellyfinLibraries(apiUrl, apiKey) { 39 | return axios.post(`/api/jellyfin/libraries`, { 40 | JELLYFIN_API_URL: apiUrl, 41 | JELLYFIN_TOKEN: apiKey 42 | }); 43 | } 44 | 45 | // Function to fetch Jellyfin Users 46 | export function fetchJellyfinUsers(apiUrl, apiKey) { 47 | return axios.post(`/api/jellyfin/users`, { 48 | JELLYFIN_API_URL: apiUrl, 49 | JELLYFIN_TOKEN: apiKey 50 | }); 51 | } -------------------------------------------------------------------------------- /client/src/api/backgroundManager.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data() { 3 | return { 4 | backgroundImageUrl: "", 5 | intervalId: null, 6 | defaultImages: [ 7 | "/images/default1.jpg", 8 | "/images/default2.jpg", 9 | "/images/default3.jpg", 10 | ], 11 | currentDefaultImageIndex: 0, 12 | }; 13 | }, 14 | methods: { 15 | startDefaultImageRotation() { 16 | this.backgroundImageUrl = 17 | this.defaultImages[this.currentDefaultImageIndex]; 18 | 19 | this.intervalId = setInterval(() => { 20 | this.currentDefaultImageIndex = 21 | (this.currentDefaultImageIndex + 1) % this.defaultImages.length; 22 | this.backgroundImageUrl = 23 | this.defaultImages[this.currentDefaultImageIndex]; 24 | }, 10000); 25 | }, 26 | async fetchRandomMovieImage(fetchImageCallback, tmdbApiKey) { 27 | const imageUrl = await fetchImageCallback(tmdbApiKey); 28 | if (imageUrl) { 29 | const img = new Image(); 30 | img.src = imageUrl; 31 | img.onload = () => { 32 | this.backgroundImageUrl = imageUrl; 33 | }; 34 | } 35 | }, 36 | startBackgroundImageRotation(fetchImageCallback, tmdbApiKey) { 37 | this.fetchRandomMovieImage(fetchImageCallback, tmdbApiKey); 38 | this.intervalId = setInterval(() => { 39 | this.fetchRandomMovieImage(fetchImageCallback, tmdbApiKey); 40 | }, 10000); 41 | }, 42 | stopBackgroundImageRotation() { 43 | if (this.intervalId) { 44 | clearInterval(this.intervalId); 45 | } 46 | }, 47 | }, 48 | beforeUnmount() { 49 | this.stopBackgroundImageRotation(); 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /client/src/api/plexApi.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export default { 4 | props: ['config'], 5 | data() { 6 | return { 7 | loading: false, 8 | loadingLibraries: false, 9 | servers: [], 10 | selectedServer: null, 11 | selectedServerConnection: null, 12 | libraries: [], 13 | selectedLibraries: [], 14 | manualConfiguration: false, 15 | manualServerAddress: '', 16 | isLoggedIn: false, 17 | users: [], // Contains the retrieved users 18 | selectedUsers: [], // Stores the selected users 19 | }; 20 | }, 21 | methods: { 22 | // Toggle and update selected libraries 23 | toggleLibrarySelection(library) { 24 | const index = this.selectedLibraries.findIndex(l => l.key === library.key); 25 | index > -1 ? this.selectedLibraries.splice(index, 1) : this.selectedLibraries.push(library); 26 | this.updateSelectedLibraries(); 27 | }, 28 | isSelected(libraryId) { 29 | return this.selectedLibraries.some(library => library.key === libraryId); 30 | }, 31 | updateSelectedLibraries() { 32 | const libraryIds = this.selectedLibraries.map(library => library.key); 33 | this.$emit('update-config', 'PLEX_LIBRARIES', libraryIds); 34 | }, 35 | loadSelectedLibraries() { 36 | if (this.config.PLEX_LIBRARIES) { 37 | this.selectedLibraries = this.libraries.filter(library => 38 | this.config.PLEX_LIBRARIES.includes(library.key) 39 | ); 40 | } 41 | }, 42 | 43 | // Toggle and update selected users 44 | toggleUserSelection(user) { 45 | const index = this.selectedUsers.findIndex(u => u.id === user.id); 46 | index > -1 ? this.selectedUsers.splice(index, 1) : this.selectedUsers.push(user); 47 | this.updateSelectedUsers(); 48 | }, 49 | isUserSelected(userId) { 50 | return this.selectedUsers.some(user => user.id === userId); 51 | }, 52 | updateSelectedUsers() { 53 | const userIds = this.selectedUsers.map(user => user.id); 54 | this.$emit('update-config', 'SELECTED_USERS', userIds); 55 | }, 56 | loadSelectedUsers() { 57 | if (this.config.SELECTED_USERS) { 58 | this.selectedUsers = this.users.filter(user => 59 | this.config.SELECTED_USERS.includes(user.id) 60 | ); 61 | } 62 | }, 63 | 64 | // Fetch users from the server 65 | async fetchUsers() { 66 | try { 67 | const response = await this.apiRequest('/api/plex/users', 'post', { 68 | PLEX_API_URL: this.config.PLEX_API_URL, 69 | PLEX_TOKEN: this.config.PLEX_TOKEN, 70 | }); 71 | 72 | if (response.status === 200 && response.data.users) { 73 | this.users = response.data.users; 74 | this.loadSelectedUsers(); 75 | } else { 76 | this.$toast.error('Failed to fetch users.'); 77 | } 78 | } catch (error) { 79 | this.$toast.error('Error fetching users.'); 80 | } 81 | }, 82 | 83 | async apiRequest(url, method = 'get', data = null) { 84 | try { 85 | const response = await axios({ 86 | url, 87 | method, 88 | data, 89 | headers: this.config.headers, 90 | }); 91 | return response; 92 | } catch (error) { 93 | console.error(`API Request error: ${error.message}`); 94 | throw error; 95 | } 96 | }, 97 | async loginWithPlex() { 98 | try { 99 | this.loading = true; 100 | const response = await this.apiRequest('/api/plex/auth', 'post'); 101 | const { pin_id, auth_url } = response.data; 102 | 103 | window.open(auth_url, '_blank', 'width=800,height=600'); 104 | this.startPolling(pin_id); 105 | } catch (error) { 106 | this.$toast.error('Error during Plex login.'); 107 | } 108 | }, 109 | async startPolling(pin_id) { 110 | const interval = setInterval(async () => { 111 | try { 112 | const response = await this.apiRequest(`/api/plex/check-auth/${pin_id}`); 113 | const { auth_token } = response.data; 114 | 115 | if (auth_token) { 116 | clearInterval(interval); 117 | this.$emit('update-config', 'PLEX_TOKEN', auth_token); 118 | await this.fetchPlexServers(auth_token); 119 | this.isLoggedIn = true; 120 | } 121 | } catch (error) { 122 | console.error('Error checking Plex auth status:', error); 123 | } finally { 124 | this.loading = false; 125 | } 126 | }, 3000); 127 | }, 128 | async fetchPlexServers(auth_token) { 129 | try { 130 | const response = await this.apiRequest('/api/plex/servers', 'post', { auth_token }); 131 | if (response.status === 200 && response.data.servers) { 132 | this.servers = response.data.servers; 133 | if (this.servers.length > 0) { 134 | this.selectedServer = this.servers[0]; 135 | } 136 | } else { 137 | this.$toast.error('Failed to fetch servers.'); 138 | } 139 | } catch (error) { 140 | this.$toast.error('Error fetching Plex servers.'); 141 | } 142 | }, 143 | updateSelectedServer() { 144 | this.libraries = []; // Reset libraries if a new server is selected 145 | this.users = [] 146 | if (this.selectedServerConnection === 'manual') { 147 | this.manualConfiguration = true; 148 | } else { 149 | this.manualConfiguration = false; 150 | const { address, port, protocol } = this.selectedServerConnection; 151 | this.$emit('update-config', 'PLEX_API_URL', `${protocol}://${address}:${port}`); 152 | } 153 | }, 154 | getServerConnections() { 155 | return this.servers.reduce((connections, server) => { 156 | if (server.connections) { 157 | server.connections.forEach(connection => { 158 | connections.push({ 159 | serverName: server.name, 160 | address: connection.address, 161 | port: connection.port, 162 | protocol: connection.protocol, 163 | secure: connection.protocol === 'https', 164 | }); 165 | }); 166 | } 167 | return connections; 168 | }, []); 169 | }, 170 | async fetchLibraries() { 171 | this.loadingLibraries = true; 172 | try { 173 | const response = await this.apiRequest('/api/plex/libraries', 'post', { 174 | PLEX_API_URL: this.config.PLEX_API_URL, 175 | PLEX_TOKEN: this.config.PLEX_TOKEN, 176 | }); 177 | 178 | if (response.status === 200 && response.data.items) { 179 | this.libraries = response.data.items; 180 | this.loadSelectedLibraries(); 181 | } else { 182 | this.$toast.error('Failed to fetch libraries.'); 183 | } 184 | } catch (error) { 185 | this.$toast.error('Error fetching libraries.'); 186 | } finally { 187 | this.loadingLibraries = false; 188 | this.fetchUsers(); 189 | } 190 | }, 191 | }, 192 | mounted() { 193 | const authToken = this.config.PLEX_TOKEN; 194 | if (authToken) { 195 | this.isLoggedIn = true; 196 | this.fetchPlexServers(authToken); 197 | this.fetchUsers(); // Fetch users on component mount 198 | this.fetchLibraries(); // Fetch libraries on component mount 199 | } 200 | }, 201 | }; -------------------------------------------------------------------------------- /client/src/api/tmdbApi.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export const fetchRandomMovieImage = async (apiKey) => { 4 | if (!apiKey) return null; 5 | 6 | const randomPage = Math.floor(Math.random() * 100) + 1; 7 | try { 8 | const response = await axios.get('https://api.themoviedb.org/3/movie/popular', { 9 | params: { api_key: apiKey, page: randomPage , include_adult: false}, 10 | }); 11 | 12 | const movies = response.data.results; 13 | const randomMovie = movies[Math.floor(Math.random() * movies.length)]; 14 | return `https://image.tmdb.org/t/p/w1280${randomMovie.backdrop_path}`; 15 | } catch (error) { 16 | console.error('Failed to fetch movie image:', error); 17 | return null; 18 | } 19 | }; -------------------------------------------------------------------------------- /client/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/client/src/assets/logo.png -------------------------------------------------------------------------------- /client/src/assets/logos/emby-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/client/src/assets/logos/emby-logo.png -------------------------------------------------------------------------------- /client/src/assets/logos/jellyfin-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/client/src/assets/logos/jellyfin-logo.png -------------------------------------------------------------------------------- /client/src/assets/logos/plex-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/client/src/assets/logos/plex-logo.png -------------------------------------------------------------------------------- /client/src/assets/styles/advancedFilterConfig.css: -------------------------------------------------------------------------------- 1 | .relative input:checked~.dot { 2 | transform: translateX(100%); 3 | background-color: #34d399; 4 | /* Green color when checked */ 5 | } 6 | 7 | 8 | /* Styling for vue-multiselect */ 9 | .multiselect { 10 | border: 1px solid #4a5568; 11 | /* Match border-gray-600 */ 12 | border-radius: 0.5rem; 13 | /* Match rounded-lg */ 14 | padding: 0.0rem; 15 | color: #e5e7eb; 16 | width: 100%; 17 | } 18 | 19 | .multiselect__tag { 20 | background-color: #b84141; 21 | /* Red for excluded tags */ 22 | color: #caced8 !important; 23 | margin: 2px; 24 | font-size: 0.875rem; 25 | border-radius: 0.5rem; 26 | } 27 | 28 | .multiselect__input { 29 | background-color: transparent !important; 30 | color: #ffffff !important; 31 | /* Force light text color */ 32 | } 33 | 34 | /* Target selected single items */ 35 | .multiselect__single { 36 | color: #e5e7eb !important; 37 | background-color: transparent !important; 38 | } 39 | 40 | .multiselect__placeholder { 41 | color: #9ca3af; 42 | } 43 | 44 | .multiselect__content-wrapper { 45 | background-color: #1f2937; 46 | border: 0px solid #4b5563; 47 | border-radius: 0.5rem; 48 | } 49 | 50 | .multiselect__option--highlight { 51 | color: #e5e7eb; 52 | } 53 | 54 | .multiselect__option--selected { 55 | background-color: #b84141; 56 | color: #ffffff; 57 | } 58 | 59 | .multiselect__select, 60 | .multiselect__clear { 61 | color: #9ca3af; 62 | } 63 | 64 | .multiselect__tags { 65 | min-height: 40px; 66 | display: block; 67 | padding: 8px 40px 0 8px; 68 | border-radius: 5px; 69 | --tw-bg-opacity: 1; 70 | background-color: rgba(55, 65, 81, var(--tw-bg-opacity)); 71 | font-size: 14px; 72 | border: 0; 73 | } 74 | 75 | .multiselect-genres .multiselect__tag { 76 | background-color: #b84141; /* Rosso per i tag dei generi esclusi */ 77 | color: #ffffff !important; 78 | } 79 | 80 | /* Tag verde per il multiselect delle lingue */ 81 | .multiselect-languages .multiselect__tag { 82 | background-color: #34d399; /* Verde per i tag delle lingue preferite */ 83 | color: #ffffff !important; 84 | } 85 | .multiselect__content-wrapper::-webkit-scrollbar { 86 | width: 8px; 87 | /* Width of the scrollbar */ 88 | } 89 | 90 | .multiselect__content-wrapper::-webkit-scrollbar-track { 91 | background: #1f2937; 92 | /* Background of the scrollbar track */ 93 | border-radius: 8px; 94 | } 95 | 96 | .multiselect__content-wrapper::-webkit-scrollbar-thumb { 97 | background-color: #4b5563; 98 | /* Color of the scrollbar thumb */ 99 | border-radius: 8px; 100 | border: 2px solid #1f2937; 101 | /* Adds padding around the thumb */ 102 | } 103 | 104 | .multiselect__content-wrapper::-webkit-scrollbar-thumb:hover { 105 | background-color: #6b7280; 106 | /* Color on hover */ 107 | } 108 | 109 | /* Firefox scrollbar styling */ 110 | .multiselect__content-wrapper { 111 | scrollbar-width: thin; 112 | scrollbar-color: #4b5563 #1f2937; 113 | /* Thumb color and track color */ 114 | } -------------------------------------------------------------------------------- /client/src/assets/styles/wizard.css: -------------------------------------------------------------------------------- 1 | .wizard-container { 2 | background-color: #1a202c; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | min-height: 100vh; 7 | background-size: cover; 8 | background-position: center; 9 | background-repeat: no-repeat; 10 | transition: background-image 0.7s ease-in-out; 11 | /* Dark overlay for better text contrast */ 12 | position: relative; 13 | z-index: 1; 14 | } 15 | 16 | .wizard-container::before { 17 | content: ''; 18 | position: absolute; 19 | top: 0; 20 | left: 0; 21 | right: 0; 22 | bottom: 0; 23 | background: rgba(0, 0, 0, 0.6); 24 | z-index: -1; 25 | } 26 | 27 | .wizard-content { 28 | padding: 20px; 29 | background-color: #2d3748e7 !important; 30 | border-radius: 10px; 31 | max-width: 800px; 32 | width: 100%; 33 | } 34 | 35 | .progress-bar { 36 | width: 100%; 37 | background-color: #4a5568; 38 | border-radius: 5px; 39 | height: 8px; 40 | margin-bottom: 20px; 41 | } 42 | 43 | .progress { 44 | background-color: #3182ce; 45 | height: 100%; 46 | border-radius: 5px; 47 | transition: width 0.3s ease; 48 | } 49 | 50 | .steps-count { 51 | text-align: center; 52 | color: #cbd5e0; 53 | } 54 | 55 | .steps-count { 56 | font-size: 1rem; 57 | color: #a0aec0; 58 | text-align: center; 59 | margin-bottom: 1.5rem; 60 | } 61 | 62 | .step-summary { 63 | display: flex; 64 | justify-content: space-between; 65 | align-items: center; 66 | padding: 10px; 67 | background-color: #1a202c; 68 | border-radius: 8px; 69 | margin-bottom: 20px; 70 | color: #e2e8f0; 71 | } 72 | 73 | button { 74 | background-color: #3182ce; 75 | color: white; 76 | font-weight: bold; 77 | padding: 12px 20px; 78 | border-radius: 8px; 79 | transition: background-color 0.3s ease, transform 0.3s ease; 80 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 81 | } 82 | 83 | button:hover { 84 | background-color: #2c5282; 85 | transform: translateY(-2px); 86 | } 87 | 88 | button:disabled { 89 | background-color: #a0aec0; 90 | cursor: not-allowed; 91 | box-shadow: none; 92 | } 93 | 94 | .wizard-content { 95 | padding: 30px; 96 | background-color: #2d3748; 97 | border-radius: 15px; 98 | max-width: 850px; 99 | width: 100%; 100 | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); 101 | } 102 | 103 | input { 104 | color: #ffffff; 105 | /* Cambia il colore del testo all'interno dell'input */ 106 | background-color: #2d3748; 107 | /* Cambia anche il colore di sfondo dell'input */ 108 | border: 1px solid #4a5568; 109 | /* Colore del bordo */ 110 | padding: 10px; 111 | border-radius: 5px; 112 | font-size: 1rem; 113 | } 114 | 115 | input::placeholder { 116 | color: #a0aec0; 117 | /* Cambia il colore del placeholder */ 118 | } 119 | 120 | /* Stili per il campo select */ 121 | select { 122 | background-color: #2d3748; /* Colore di sfondo del select */ 123 | color: #ffffff; /* Colore del testo selezionato */ 124 | border: 1px solid #4a5568; /* Colore del bordo */ 125 | padding: 10px; 126 | border-radius: 5px; 127 | font-size: 1rem; 128 | appearance: none; /* Nasconde la freccia nativa del browser */ 129 | background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg xmlns='http://www.w3.org/2000/svg' width='292.362' height='292.362' viewBox='0 0 292.362 292.362'%3E%3Cpath fill='%23ffffff' d='M287.929 69.574c-7.614-7.611-19.978-7.611-27.586 0L146.181 183.736 32.021 69.574c-7.614-7.611-19.978-7.611-27.591 0-7.614 7.614-7.614 19.978 0 27.593l131.743 131.746c3.807 3.807 8.794 5.711 13.787 5.711 4.993 0 9.98-1.904 13.787-5.711l131.743-131.746c7.613-7.615 7.613-19.979 0-27.593z'/%3E%3C/svg%3E"); 130 | background-repeat: no-repeat; 131 | background-position: right 10px top 50%; /* Posiziona la freccia a destra */ 132 | background-size: 10px; /* Dimensione della freccia */ 133 | } 134 | 135 | /* Cambia il colore delle opzioni del dropdown */ 136 | select option { 137 | background-color: #2d3748; /* Colore di sfondo delle opzioni */ 138 | color: #ffffff; /* Colore del testo delle opzioni */ 139 | } 140 | 141 | /* Aggiungi effetto hover alle opzioni */ 142 | select option:hover { 143 | background-color: #4a5568; /* Colore di sfondo quando l'opzione è selezionata */ 144 | color: #ffffff; /* Colore del testo */ 145 | } 146 | 147 | /* Cambia l'aspetto del select quando è in focus */ 148 | select:focus { 149 | outline: none; /* Rimuove il bordo di default */ 150 | border: 1px solid #3182ce; /* Aggiunge un bordo blu quando è in focus */ 151 | } 152 | 153 | .fade-enter-active, .fade-leave-active { 154 | transition: opacity 0.3s ease; 155 | } 156 | .fade-enter, .fade-leave-to /* .fade-leave-active in <2.1.8 */ { 157 | 158 | opacity: 0; 159 | } 160 | 161 | .update-notification { 162 | background-color: #2d3748; /* Darker gray for contrast */ 163 | border-radius: 0.5rem; 164 | padding: 0.5rem; 165 | margin-top: 0.5rem; 166 | text-align: center; 167 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); 168 | transition: transform 0.2s ease-in-out; 169 | } 170 | 171 | .update-notification:hover { 172 | transform: scale(1.05); /* Small scale effect on hover */ 173 | } 174 | 175 | .attached-logo { 176 | width: 100px; 177 | height: auto; 178 | display: block; 179 | margin: 0 auto; 180 | margin-bottom: 30px; 181 | } 182 | 183 | @media (max-width: 768px) { 184 | .wizard-content { 185 | padding: 20px; 186 | font-size: 0.9rem; 187 | } 188 | 189 | .wizard-title { 190 | font-size: 1.8rem; 191 | } 192 | 193 | .steps-count { 194 | font-size: 0.85rem; 195 | } 196 | 197 | button { 198 | padding: 10px 15px; 199 | } 200 | 201 | .attached-logo { 202 | width: 80px; 203 | } 204 | .version-tag { 205 | width: 35px; 206 | height: 35px; 207 | font-size: 0.75rem; 208 | } 209 | } -------------------------------------------------------------------------------- /client/src/assets/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /client/src/components/AppFooter.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 69 | 70 | 187 | -------------------------------------------------------------------------------- /client/src/components/ConfigWizard.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 220 | 221 | -------------------------------------------------------------------------------- /client/src/components/LogsComponent.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 169 | 170 | 321 | -------------------------------------------------------------------------------- /client/src/components/configWizard/AdditionalSettings.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 94 | -------------------------------------------------------------------------------- /client/src/components/configWizard/DbConfig.vue: -------------------------------------------------------------------------------- 1 | 84 | 85 | 145 | -------------------------------------------------------------------------------- /client/src/components/configWizard/JellyfinConfig.vue: -------------------------------------------------------------------------------- 1 | 79 | 80 | 204 | -------------------------------------------------------------------------------- /client/src/components/configWizard/MediaServiceSelection.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 51 | 52 | 127 | -------------------------------------------------------------------------------- /client/src/components/configWizard/PlexConfig.vue: -------------------------------------------------------------------------------- 1 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /client/src/components/configWizard/SeerConfig.vue: -------------------------------------------------------------------------------- 1 | 111 | 112 | 205 | -------------------------------------------------------------------------------- /client/src/components/configWizard/TmdbConfig.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 89 | -------------------------------------------------------------------------------- /client/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from './App.vue'; 3 | import ToastPlugin from 'vue-toast-notification'; 4 | import 'vue-toast-notification/dist/theme-bootstrap.css'; 5 | import axios from 'axios'; 6 | import router from './router'; 7 | 8 | const app = createApp(App); 9 | 10 | if (process.env.NODE_ENV === 'development') { 11 | axios.defaults.baseURL = 'http://localhost:5000'; 12 | } 13 | 14 | const options = { 15 | position: 'top-right', 16 | timeout: 5000, 17 | closeOnClick: false, 18 | pauseOnHover: true, 19 | draggable: false, 20 | showCloseButtonOnHover: true, 21 | closeButton: 'button', 22 | icon: true, 23 | rtl: false, 24 | }; 25 | 26 | app.use(ToastPlugin, options); -------------------------------------------------------------------------------- /client/src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router'; 2 | import RequestsPage from '@/components/RequestsPage.vue'; 3 | import ConfigWizard from '@/components/ConfigWizard.vue'; 4 | import axios from 'axios'; 5 | import { createApp } from 'vue'; 6 | import App from '../App.vue'; 7 | import 'vue-toast-notification/dist/theme-bootstrap.css'; 8 | import ToastPlugin from 'vue-toast-notification'; 9 | 10 | async function loadConfig() { 11 | if (process.env.NODE_ENV === 'development') { 12 | axios.defaults.baseURL = 'http://localhost:5000'; 13 | } 14 | 15 | try { 16 | const response = await axios.get('/api/config/fetch'); 17 | return response.data.SUBPATH || ''; 18 | } catch (error) { 19 | throw new Error('Unable to load the configuration file'); 20 | } 21 | } 22 | 23 | async function createAppRouter() { 24 | const subpath = await loadConfig(); 25 | 26 | const routes = [ 27 | { path: `/requests`, name: 'RequestsPage', component: RequestsPage }, 28 | { path: `/`, name: 'Home', component: ConfigWizard }, 29 | ]; 30 | 31 | return createRouter({ 32 | history: createWebHistory(subpath || '/'), 33 | routes 34 | }); 35 | } 36 | 37 | createAppRouter().then(router => { 38 | const app = createApp(App); 39 | app.use(router); 40 | app.mount('#app'); 41 | const options = { 42 | position: 'top-right', 43 | timeout: 5000, 44 | closeOnClick: false, 45 | pauseOnHover: true, 46 | draggable: false, 47 | showCloseButtonOnHover: true, 48 | closeButton: 'button', 49 | icon: true, 50 | rtl: false, 51 | }; 52 | 53 | app.use(ToastPlugin, options); 54 | }).catch(error => { 55 | console.error('Error loading the router:', error); 56 | }); 57 | -------------------------------------------------------------------------------- /client/vue.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('@vue/cli-service') 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | transpileDependencies: [], 6 | }; 7 | 8 | module.exports = defineConfig({ 9 | configureWebpack: { 10 | plugins: [ 11 | new webpack.DefinePlugin({ 12 | __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false', 13 | }) 14 | ], 15 | }, 16 | }); -------------------------------------------------------------------------------- /config/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | 4 | [program:uvicorn] 5 | command=uvicorn api_service.app:asgi_app --host 0.0.0.0 --port %(ENV_SUGGESTARR_PORT)s --log-level %(ENV_LOG_LEVEL)s 6 | directory=/app 7 | autorestart=true 8 | stdout_logfile=/dev/stdout 9 | stderr_logfile=/dev/stderr 10 | stdout_maxbytes=0 11 | stderr_maxbytes=0 12 | stdout_logfile_maxbytes = 0 13 | stderr_logfile_maxbytes = 0 14 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build the client 2 | FROM node:22 AS client-builder 3 | WORKDIR /app/client 4 | COPY client/package*.json ./ 5 | RUN npm install && npm cache clean --force 6 | COPY client/ . 7 | RUN npm run build 8 | 9 | # Stage 2: Create the final image 10 | FROM python:3.13-alpine AS prod 11 | 12 | # Set the working directory for the api_service 13 | WORKDIR /app/api_service 14 | 15 | # Copy and install Python dependencies first (use cache efficiently) 16 | COPY api_service/requirements.txt /app/api_service/ 17 | RUN pip install --no-cache-dir -r requirements.txt 18 | 19 | # Copy the client build files from the previous stage 20 | COPY --from=client-builder /app/client/dist /app/static 21 | 22 | # Copy the api_service source code 23 | COPY api_service/ /app/api_service/ 24 | 25 | # Copy docker entrypoint script 26 | COPY docker/docker_entrypoint.sh /app/ 27 | RUN chmod +x /app/docker_entrypoint.sh 28 | 29 | # Create log files for Gunicorn 30 | RUN touch /var/log/gunicorn.log /var/log/gunicorn_error.log 31 | 32 | # Expose the port used by Gunicorn 33 | ARG SUGGESTARR_PORT=5000 34 | EXPOSE ${SUGGESTARR_PORT} 35 | 36 | # Start Supervisor to manage Gunicorn 37 | ENV LOG_LEVEL=info 38 | # Set the port dynamically 39 | ENV SUGGESTARR_PORT=${SUGGESTARR_PORT} 40 | 41 | # Use the custom port in the entrypoint command 42 | ENTRYPOINT ["/app/docker_entrypoint.sh"] 43 | CMD ["--host", "0.0.0.0", "--port", "${SUGGESTARR_PORT}", "--log-level", "info"] 44 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | suggestarr: 3 | image: ciuse99/suggestarr:latest 4 | container_name: SuggestArr 5 | restart: always 6 | ports: 7 | - "${SUGGESTARR_PORT:-5000}:${SUGGESTARR_PORT:-5000}" 8 | volumes: 9 | - ./config_files:/app/config/config_files 10 | environment: 11 | - LOG_LEVEL=${LOG_LEVEL:-info} 12 | - SUGGESTARR_PORT=${SUGGESTARR_PORT:-5000} 13 | - TZ=Europe/Rome 14 | -------------------------------------------------------------------------------- /docker/docker_entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | args="$@" 5 | 6 | cd /app && exec uvicorn api_service.app:asgi_app $(eval echo "$args") 7 | -------------------------------------------------------------------------------- /unraid/ca_profile.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/master/unraid/logo.png 4 | 5 | The Official SuggestArr Repository 6 | 7 | https://buymeacoffee.com/suggestarr 8 | If you appreciate our work, please consider supporting us by buying us a coffee! 9 | -------------------------------------------------------------------------------- /unraid/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/fe411e8bef013729597bb8d20e4b049879830250/unraid/logo.png -------------------------------------------------------------------------------- /unraid/template.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | SuggestArr 4 | ciuse99/suggestarr:latest 5 | https://hub.docker.com/r/ciuse99/suggestarr 6 | host 7 | bash 8 | false 9 | https://github.com/giuseppe99barchetta/suggestarr/issues 10 | https://github.com/giuseppe99barchetta/suggestarr 11 | Automatically request suggested movies and TV shows to Jellyseerr based on recently watched. 12 | Downloaders: MediaApp:Video 13 | http://[IP]:[PORT:5000] 14 | https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/master/unraid/logo.png 15 | https://raw.githubusercontent.com/giuseppe99barchetta/SuggestArr/master/unraid/suggestarr.xml 16 | 17 | https://github.com/giuseppe99barchetta/SuggestArr 18 | 19 | 5155 20 | 5000 21 | /mnt/user/appdata/suggestarr 22 | 23 | --------------------------------------------------------------------------------