├── .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 | 
6 |
7 | 
8 | 
9 | [](https://github.com/giuseppe99barchetta/SuggestArr/wiki)
10 | 
11 | 
12 |
13 | [](https://buymeacoffee.com/suggestarr)
14 | [](https://www.reddit.com/r/selfhosted/comments/1gb4swg/release_major_update_for_suggestarr_now/)
15 | 
16 | [](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 |
14 | We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/client/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
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 |
2 |
40 |
41 |
42 |
69 |
70 |
187 |
--------------------------------------------------------------------------------
/client/src/components/ConfigWizard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
8 |
9 |
10 |
13 |
{{ currentStep }} / {{ steps.length }} Steps Complete
14 |
15 |
16 |
17 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
220 |
221 |
--------------------------------------------------------------------------------
/client/src/components/LogsComponent.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | All Severities
10 | DEBUG
11 | INFO
12 | WARNING
13 | ERROR
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | TIMESTAMP
26 | SEVERITY
27 | LABEL
28 | MESSAGE
29 |
30 |
31 |
32 |
33 |
34 | {{ log.dateTime }}
35 | {{ log.severity }}
36 | {{ log.tag }}
37 | {{ log.message }}
38 |
39 | Copy
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
169 |
170 |
321 |
--------------------------------------------------------------------------------
/client/src/components/configWizard/AdditionalSettings.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Additional Configuration
4 |
Suggestarr scans your recent viewing history and finds similar content based on the settings below. Adjust these options to control the number and type of suggestions generated.
5 |
6 |
7 |
Max Similar Movies:
8 |
Define how many similar movies Suggestarr should find for each movie viewed. For example, if set to 1, Suggestarr will suggest one similar movie for every movie in the recent viewing history.
9 |
13 |
14 |
15 |
Max Similar TV Shows:
16 |
Define how many similar TV shows Suggestarr should find for each show in the viewing history. For example, if set to 2, it will suggest up to two similar shows for every recently viewed show.
17 |
21 |
22 |
23 |
Max Content Checks:
24 |
Set the maximum number of items from your recent viewing history Suggestarr should analyze. For instance, if set to 3, it will look for similar items based on the last three movies or shows viewed.
25 |
29 |
30 |
31 |
Search Size:
32 |
Specify how many suggestions Suggestarr should generate for each item it analyzes. For example, if set to 5, Suggestarr will find up to 5 possible suggestions for each movie or show it checks.
33 |
37 |
38 |
39 |
Cron Times:
40 |
Define when Suggestarr should run searches using a cron schedule. For example, "0 0 * * *" means checks will occur every day at midnight. Incorrect cron formats will show an error.
41 |
45 |
46 |
{{ cronDescription }}
47 |
{{ cronError }}
48 |
49 |
Base URL Subpath:
50 |
Specify the subpath where Suggestarr will be accessed. For example, "/suggestarr" would make Suggestarr accessible at "yourdomain.com/suggestarr". If left empty, it defaults to the root path.
51 |
55 |
56 |
57 | Back
58 | Save
59 |
60 |
61 |
62 |
63 |
94 |
--------------------------------------------------------------------------------
/client/src/components/configWizard/DbConfig.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Database Configuration
4 |
Configure the database settings below to connect to your preferred database (PostgreSQL, MySQL/MariaDB, or SQLite). By default, SQLite is used for the standard configuration.
5 |
6 |
7 |
Database Type:
8 |
Select the database to store request made from SuggestArr.
9 |
11 | SQLite (Default)
12 | PostgreSQL
13 | MySQL/MariaDB
14 |
15 |
16 |
17 |
18 |
Database Host:
19 |
Enter the database host address
20 |
24 |
25 |
26 |
Database Port:
27 |
Enter the port number for the database. Default for PostgreSQL is 5432, MySQL/MariaDB is 3306.
28 |
32 |
33 |
50 |
51 |
52 |
Database Name:
53 |
Enter the name of the database you wish to connect to.
54 |
55 | Important: The database must be created before connecting.
56 |
57 |
61 |
62 |
63 |
{{ dbError }}
64 |
{{ dbSuccess }}
65 |
66 |
67 |
68 |
70 | {{ buttonText }}
71 |
72 |
73 |
74 |
75 |
76 |
77 | Back
78 | Next
81 |
82 |
83 |
84 |
85 |
145 |
--------------------------------------------------------------------------------
/client/src/components/configWizard/JellyfinConfig.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{ serviceName }} Configuration
4 |
5 | To obtain your {{ serviceName }} API Key, follow these steps: Open the {{ serviceName }} web interface, navigate to the Control Panel, select "API Keys," create a new key, and copy it for use in this configuration.
6 |
7 |
8 |
9 |
{{ serviceName }} URL:
10 |
13 |
14 |
15 |
{{ serviceName }} API Key:
16 |
17 |
21 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
Select the {{ serviceName }} libraries you want to include:
36 |
(If no libraries are selected, all libraries will be included.)
37 |
38 |
43 | {{ library.Name }}
44 |
45 |
46 |
47 |
48 |
49 |
50 |
Select the {{ serviceName }} users you want to include:
51 |
(If no users are selected, all users will be included.)
52 |
53 |
58 | {{ user.name }}
59 |
60 |
61 |
62 |
63 |
64 |
66 | Failed to validate {{ serviceName }} Key or retrieve libraries.
67 |
68 |
69 |
70 |
71 | Back
73 | Next Step
76 |
77 |
78 |
79 |
80 |
204 |
--------------------------------------------------------------------------------
/client/src/components/configWizard/MediaServiceSelection.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Select the media service you want to use:
4 |
19 |
20 |
21 |
23 | Next Step
24 |
25 |
26 |
27 |
28 |
29 |
51 |
52 |
127 |
--------------------------------------------------------------------------------
/client/src/components/configWizard/PlexConfig.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Plex Configuration
4 |
5 | Login using your Plex account.
6 |
7 |
8 |
9 |
10 |
11 | Login with Plex
12 |
13 |
14 |
15 |
16 |
Select a Plex
17 | Server and Connection:
18 |
19 |
20 |
21 |
24 |
26 | {{ connection.serverName }} - {{ connection.address }}:{{ connection.port }} ({{
27 | connection.protocol }}) {{ connection.secure ? '[Secure]' : '[Insecure]' }}
28 |
29 | Manual Configuration
30 |
31 |
32 |
33 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
Manual Server Configuration
43 |
44 |
Server
45 | Address:
46 | (e.g., http://192.168.1.10:32400)
47 |
48 |
49 |
50 |
51 |
52 |
56 |
57 |
58 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
Select the Plex libraries you want to include:
68 |
(If no libraries are selected, all libraries will be included.)
69 |
70 |
74 | {{ library.title }}
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | Select the Plex Users you want to include
83 | BETA
84 |
85 |
(If no users are selected, all users will be included.)
86 |
87 |
96 | {{ user.name }}
97 |
98 |
99 |
100 |
101 |
102 |
103 |
105 | Back
106 |
107 |
110 | Next Step
111 |
112 |
113 |
114 |
115 |
116 |
117 |
--------------------------------------------------------------------------------
/client/src/components/configWizard/SeerConfig.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Overseer/Jellyseer API Configuration
4 |
To obtain your Overseer/Jellyseer API Key, open the Overseer/Jellyseer interface,
5 | navigate to Settings, locate the "API Key" section, and copy your key for use in this configuration.
6 |
7 |
8 |
Overseer/Jellyseer
9 | URL:
10 |
14 |
15 |
16 |
Overseer/Jellyseer API
17 | Key:
18 |
19 |
23 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
40 | Failed to validate Overseer/Jellyseer Key.
41 |
42 |
43 |
44 |
45 |
Select a local
46 | User:
47 |
48 |
49 | Only local users of Overseer/Jellyseer can be selected. Selecting a specific user is useful if you want to
50 | disable automatic approval of requests and manually approve them before automatic downloading.
51 | This step is optional. If no user is selected, the administrator account will be used to make requests.
52 |
53 |
96 |
97 |
98 |
99 |
100 |
101 | Back
103 |
106 | Next
107 |
108 |
109 |
110 |
111 |
112 |
205 |
--------------------------------------------------------------------------------
/client/src/components/configWizard/TmdbConfig.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
TMDB Configuration
4 |
5 | You can get your TMDB API Key by signing up at
6 | The Movie Database .
7 |
8 |
9 |
10 |
TMDB API Key:
11 |
12 |
16 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | Failed to validate TMDB API Key.
33 |
34 |
35 |
36 |
37 |
39 | Back
40 |
41 |
44 | Next Step
45 |
46 |
47 |
48 |
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 |
--------------------------------------------------------------------------------