├── .dockerignore ├── .github └── workflows │ └── docker-packages.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app.py ├── docker-compose.yml ├── entrypoint.sh ├── env.example └── requirements.txt /.dockerignore: -------------------------------------------------------------------------------- 1 | # Environments 2 | .env 3 | .venv 4 | env/ 5 | venv/ 6 | ENV/ 7 | env.bak/ 8 | venv.bak/ 9 | 10 | # Custom 11 | logs 12 | log 13 | data 14 | config 15 | desktop.ini 16 | *.json 17 | model 18 | *.prof 19 | *.austin 20 | *_backups 21 | test 22 | test.py 23 | venv* 24 | *.etaflag* 25 | *.gamingflag 26 | hardlinks.json 27 | output.txt 28 | *.exe -------------------------------------------------------------------------------- /.github/workflows/docker-packages.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Set up repository 13 | uses: actions/checkout@v3 14 | 15 | - name: Convert GITHUB_REPOSITORY to lowercase 16 | run: echo "IMAGE_NAME=$(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV 17 | 18 | - name: Set up QEMU 19 | uses: docker/setup-qemu-action@v3 20 | 21 | - name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v3 23 | 24 | - name: Login to GitHub Container Registry 25 | uses: docker/login-action@v3 26 | with: 27 | registry: ghcr.io 28 | username: ${{ github.actor }} 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: Determine year.month tag and increment if necessary 32 | id: determine_tag 33 | run: | 34 | YEAR_MONTH=$(date +"%y.%m") 35 | git fetch --tags 36 | latest_tag=$(git tag --list "${YEAR_MONTH}*" | sort -V | tail -n 1) 37 | 38 | if [ -z "$latest_tag" ]; then 39 | IMAGE_TAG="${YEAR_MONTH}" 40 | elif [[ "$latest_tag" == "$YEAR_MONTH" ]]; then 41 | IMAGE_TAG="${YEAR_MONTH}.1" 42 | else 43 | build_number=$(echo $latest_tag | awk -F '.' '{print $NF}') 44 | build_number=$((build_number + 1)) 45 | IMAGE_TAG="${YEAR_MONTH}.${build_number}" 46 | fi 47 | 48 | echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV 49 | 50 | - name: Create and push the new tag 51 | run: | 52 | git config --global user.name "GitHub Actions" 53 | git config --global user.email "actions@github.com" 54 | git tag ${{ env.IMAGE_TAG }} 55 | git push origin ${{ env.IMAGE_TAG }} 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | 59 | - name: Build and push Docker image 60 | uses: docker/build-push-action@v5 61 | with: 62 | push: true 63 | tags: | 64 | ghcr.io/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} 65 | ghcr.io/${{ env.IMAGE_NAME }}:latest 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Custom 132 | logs 133 | config 134 | desktop.ini 135 | *.json 136 | model 137 | *.prof 138 | *.austin 139 | *_backups 140 | test 141 | test.py 142 | venv* 143 | *.etaflag* 144 | *.gamingflag 145 | hardlinks.json 146 | output.txt 147 | *.exe -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-alpine 2 | 3 | LABEL maintainer="fahmula" 4 | 5 | WORKDIR /app 6 | 7 | COPY requirements.txt /app/ 8 | 9 | RUN pip install --no-cache-dir -r requirements.txt 10 | 11 | COPY . /app/ 12 | 13 | ENV PYTHONUNBUFFERED=1 14 | 15 | ENTRYPOINT ["/app/entrypoint.sh"] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Fahmula 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Emby Merge Versions 2 | 3 | ## Introduction 4 | The Emby Webhook Automation is a Python script that serves as a webhook listener for the Emby media server. It's designed to automate the merging of movies in your Emby library based on Tmdb ID received through webhooks. 5 | 6 | ## Features 7 | - Automatically merge two movies in the Emby library based on matching Tmdb ID. 8 | - Logs the merge results, including successful and unsuccessful merges. 9 | - Supports an ignore list to exclude specific libraries from the merge process. 10 | 11 | ## Prerequisites 12 | Before using this script, ensure you have the following: 13 | - Python 3.12 installed 14 | - Required Python libraries: Flask, requests 15 | - Docker (optional, for Docker installation) 16 | 17 | ## Installation 18 | 19 | ### Traditional Installation 20 | 1. Clone the repository. 21 | 2. Install the requirements using `pip install -r requirements.txt`. 22 | 3. Set up your configuration variables directly in the Python file or pass them as environment variables when running the script. 23 | 4. Run the application using `python3 main.py`. 24 | 25 | ### Docker Installation 26 | If you have Docker and Docker Compose installed, you can use the provided `docker-compose.yml` file. 27 | 28 | To run the application: 29 | docker-compose up 30 | 31 | ## Usage 32 | 33 | ### Setting up Emby Webhook 34 | 35 | #### For Emby Server 4.7 or Lower: 36 | 1. Go to Emby settings. 37 | 2. Choose `Webhook` and add a new webhook. 38 | 3. Set the server to the Flask application's endpoint (e.g., `http://192.168.1.1:5000/emby-webhook`). 39 | 4. Under `Library`, select `New Media Added`. 40 | 41 | #### For Emby Server 4.8 or Higher: 42 | 1. Go to Emby settings. 43 | 2. Choose `Notification` and add a new notification. 44 | 3. Select `Webhooks` as the notification type. 45 | 4. Set the server to the Flask application's endpoint (e.g., `http://192.168.1.5:5000/emby-webhook`). 46 | 5. You can set `Request content type` to either `multipart/form-data` or `application/json`. 47 | 6. Under `Library`, select `New Media Added`. 48 | 49 | ### Configuring the Ignore Library Feature 50 | To exclude specific movie libraries from the merge process, use the `IGNORE_LIBRARY` variable in the `docker-compose.yml` file. This is particularly useful if you have separate libraries for different types of content that you do not wish to merge. 51 | 52 | For example, to ignore libraries named "trending" and "other_library": 53 | IGNORE_LIBRARY="trending,other_library" 54 | 55 | If your library path is `/data/media/trending`, set: 56 | IGNORE_LIBRARY="trending" 57 | 58 | ### Configuring the Merge on Start Feature 59 | To automatically merge all movies at startup, set the `MERGE_ON_START` variable in the docker-compose.yml file to `yes`. After the initial merge has completed, you can set it back to `no` to prevent unnecessary merges on subsequent restarts. 60 | 61 | For example: 62 | 63 | environment: 64 | - `MERGE_ON_START`=`yes` 65 | 66 | Once done, you can revert it: 67 | 68 | environment: 69 | - `MERGE_ON_START`=`no` 70 | 71 | 72 | ### Logs 73 | Logs are stored in the directory you mapped in the `docker-compose.yml`. In the example above, logs will be stored at: 74 | /mnt/cache/appdata/merge-versions/logs 75 | 76 | ## Contributing 77 | Contributions are welcome! Feel free to open issues or submit pull requests for new features, bug fixes, or improvements. 78 | 79 | ## License 80 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 81 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import logging 4 | import requests 5 | from logging.handlers import TimedRotatingFileHandler 6 | from flask import Flask, request 7 | from dotenv import load_dotenv 8 | from pathlib import Path 9 | 10 | # Load environment variables 11 | load_dotenv() 12 | 13 | # Flask application 14 | app = Flask(__name__) 15 | 16 | # Set up logging 17 | log_folder = Path("logs") 18 | log_folder.mkdir(exist_ok=True) 19 | log_filename = log_folder / "emby-merge-version.log" 20 | logging.basicConfig( 21 | level=logging.INFO, 22 | format="%(asctime)s - %(levelname)s - %(message)s" 23 | ) 24 | rotating_handler = TimedRotatingFileHandler( 25 | log_filename, when="midnight", interval=1, backupCount=7 26 | ) 27 | rotating_handler.setFormatter( 28 | logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") 29 | ) 30 | logging.getLogger().addHandler(rotating_handler) 31 | 32 | # Emby server settings 33 | EMBY_BASE_URL = os.environ.get("EMBY_BASE_URL") 34 | EMBY_API_KEY = os.environ.get("EMBY_API_KEY") 35 | IGNORE_LIBRARY = os.environ.get("IGNORE_LIBRARY", "") 36 | MERGE_ON_START = os.environ.get("MERGE_ON_START", "").lower() == "yes" 37 | 38 | # Requests session 39 | session = requests.Session() 40 | session.headers.update({"accept": "application/json"}) 41 | 42 | # Helper Functions 43 | def check_ignore_list(item): 44 | """Check if the item should be ignored based on the ignore library list.""" 45 | ignore_list = [id.strip() for id in IGNORE_LIBRARY.split(",") if id.strip()] 46 | return all(library not in item.get("Path", "") for library in ignore_list) 47 | 48 | def merge_movies(movies): 49 | """Merge movies based on their IDs.""" 50 | for movie, movie_ids in movies.items(): 51 | if len(movie_ids) == 2: 52 | params = {"Ids": movie_ids, "api_key": EMBY_API_KEY} 53 | try: 54 | response = session.post(f"{EMBY_BASE_URL}/emby/Videos/MergeVersions", params=params) 55 | response.raise_for_status() 56 | logging.info(f"Merge successful for movie: {movie}") 57 | except requests.exceptions.RequestException as e: 58 | logging.error(f"Error merging movies: {e}") 59 | elif len(movie_ids) > 2: 60 | logging.info(f"More than two movies found for {movie}, no merge performed.") 61 | else: 62 | logging.info(f"Not enough movie IDs found for {movie}, no merge performed.") 63 | 64 | def search_movies(prov_id=None): 65 | """Search for movies, optionally filtered by provider ID.""" 66 | params = { 67 | "Recursive": "true", 68 | "Fields": "ProviderIds, Path", 69 | "IncludeItemTypes": "Movie", 70 | "api_key": EMBY_API_KEY, 71 | } 72 | if prov_id: 73 | params["AnyProviderIdEquals"] = prov_id 74 | 75 | try: 76 | response = session.get(f"{EMBY_BASE_URL}/emby/Items", params=params) 77 | response.raise_for_status() 78 | movies_data = {} 79 | for item in response.json().get("Items", []): 80 | if check_ignore_list(item): 81 | item_id = item["Id"] 82 | item_name = item["Name"] 83 | movies_data.setdefault(item_name, []).append(item_id) 84 | return movies_data 85 | except requests.exceptions.RequestException as e: 86 | logging.error(f"Error searching for movies: {e}") 87 | return {} 88 | 89 | def merge_on_start(): 90 | """Perform merging of movies at application start.""" 91 | movies = search_movies() 92 | if movies: 93 | merge_movies(movies) 94 | 95 | # Route Definitions 96 | @app.route("/emby-webhook", methods=["POST"]) 97 | def webhook_listener(): 98 | """Handle incoming webhook requests.""" 99 | try: 100 | data = json.loads(request.form.get("data") or request.data) 101 | except (KeyError, json.JSONDecodeError): 102 | logging.error("Invalid data received in webhook.") 103 | return "Invalid data", 400 104 | 105 | provider_id = "Tmdb" 106 | if "ProviderIds" in data.get("Item", {}) and provider_id in data["Item"]["ProviderIds"]: 107 | prov_key = provider_id.lower() 108 | prov_value = data["Item"]["ProviderIds"][provider_id] 109 | movies = search_movies(f"{prov_key}.{prov_value}") 110 | if movies: 111 | merge_movies(movies) 112 | 113 | return "Success", 200 114 | 115 | if MERGE_ON_START: 116 | merge_on_start() 117 | 118 | # Application Startup 119 | if __name__ == "__main__": 120 | app.run(host="0.0.0.0", port=5000) 121 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | emby-merge-versions: 4 | container_name: emby-merge-versions 5 | image: ghcr.io/fahmula/emby-merge-versions:latest 6 | restart: unless-stopped 7 | ports: 8 | - "5000:5000" 9 | environment: 10 | - EMBY_BASE_URL=http://192.168.1.5:8096 11 | - EMBY_API_KEY=123456789 12 | - IGNORE_LIBRARY=trending 13 | - MERGE_ON_START=no 14 | volumes: 15 | - /mnt/cache/appdata/merge-versions/logs:/app/logs 16 | 17 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Execute Gunicorn command 4 | gunicorn -w 1 -b 0.0.0.0:5000 app:app 5 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | EMBY_BASE_URL="http://192.168.1.1:8096" 2 | EMBY_API_KEY="93023d35d5e8418d88aab354841ee2ac" 3 | IGNORE_LIBRARY="trending" 4 | MERGE_ON_START="no" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==3.0.0 2 | gunicorn==21.2.0 3 | python-dotenv==1.0.0 4 | requests==2.31.0 5 | --------------------------------------------------------------------------------