├── .github └── workflows │ └── release.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── api.py ├── app.py ├── customprocessor.py ├── nameprocessor.py ├── plexapihandler.py ├── requirements.txt └── tests ├── app_test.py └── processor_test.py /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # GitHub recommends pinning actions to a commit SHA. 7 | # To get a newer version, you will need to update the SHA. 8 | # You can also reference a tag or branch, but the action may change without warning. 9 | 10 | name: Publish Docker image 11 | 12 | on: 13 | release: 14 | types: [published] 15 | 16 | jobs: 17 | push_to_registry: 18 | name: Push Docker image to Docker Hub 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Check out the repo 22 | uses: actions/checkout@v3 23 | 24 | - name: Log in to Docker Hub 25 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 26 | with: 27 | username: ${{ secrets.DOCKER_USERNAME }} 28 | password: ${{ secrets.DOCKER_PASSWORD }} 29 | 30 | - name: Extract metadata (tags, labels) for Docker 31 | id: meta 32 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 33 | with: 34 | images: dantebarba/autoscan-adapter 35 | 36 | - name: Build and push Docker image 37 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 38 | with: 39 | context: . 40 | push: true 41 | tags: ${{ steps.meta.outputs.tags }} 42 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.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 | .vscode -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY requirements.txt requirements.txt 6 | RUN pip3 install -r requirements.txt 7 | 8 | COPY . . 9 | 10 | ENV PLEX_URL "" 11 | ENV PLEX_TOKEN "" 12 | ENV ANALYZE_MEDIA "" 13 | ENV REFRESH_MEDIA "true" 14 | ENV LOG_LEVEL "INFO" 15 | ENV SLEEP_INTERVAL "0" 16 | ENV DIRECTORY_PROC_MODULE "nameprocessor" 17 | 18 | CMD [ "python3", "-m" , "flask", "run", "--host=0.0.0.0"] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Dante Barba 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Autoscan Plex Adapter 2 | 3 | This app creates a bridge between [cloudb0x/autoscan](https://github.com/Cloudbox/autoscan) and Plex to enable metadata refresh. Works by mimicking an autoscan server. When the adapter receives the directories it connects to the plex instance and iterates all movies and episodes looking for a directory match. If a match is found the directory is added to the processing list and the metadata element gets refreshed in Plex. 4 | 5 | **Disclaimer:** This app is WIP and has no authentication. Use it at your own risk. 6 | 7 | ## Installation 8 | 9 | __tested under python 3.10__ 10 | 11 | ```bash 12 | pip install -r requirements.txt 13 | ``` 14 | 15 | ## Usage 16 | 17 | 1. Create an `.env` file with the environment secrets 18 | 19 | ```bash 20 | PLEX_URL=http://plex.domain.tld:32400 21 | PLEX_TOKEN=YOUR-PLEX-TOKEN 22 | ANALYZE_MEDIA="" # perform analysis of the media files after refresh. Empty or unset to disable 23 | REFRESH_MEDIA="true" # perform metadata refresh of the media files. Active by default. 24 | SLEEP_INTERVAL="0" # wait before starting the scanning process after each request. default is 0 (disabled) 25 | LOG_LEVEL="INFO" # the logging level for the application. Available values are DEBUG, INFO, WARNING, ERROR. Default is INFO 26 | DIRECTORY_PROC_MODULE="nameprocessor" # directory name processor. Explained in more detail below 27 | ``` 28 | 29 | 2. Run the python flask server 30 | 31 | ```bash 32 | python -m flask run -h 0.0.0.0 33 | ``` 34 | 35 | 3. Add your autoscan adapter target to your autoscan config.yml 36 | 37 | ```yml 38 | targets: 39 | plex: 40 | - url: http://plex:32400 # URL of your Plex server 41 | token: XXXX # Plex API Token 42 | autoscan: 43 | - url: http://autoscan-adapter:5000 # URL of your autoscan adapter 44 | ``` 45 | 46 | __By default flask runs on port 5000__ 47 | 48 | ## Docker 49 | 50 | This is a docker-compose.yml example. 51 | 52 | ```yaml 53 | version: '3.7' 54 | 55 | services: 56 | autoscan: 57 | image: cloudb0x/autoscan 58 | container_name: autoscan 59 | restart: unless-stopped 60 | environment: 61 | PGID: $PGID 62 | PUID: $PUID 63 | volumes: 64 | - "./config:/config" 65 | depends_on: 66 | - autoscan-adapter 67 | 68 | autoscan-adapter: 69 | image: dantebarba/autoscan-adapter 70 | container_name: autoscan-adapter 71 | restart: unless-stopped 72 | environment: 73 | PGID: $PGID 74 | PUID: $PUID 75 | PLEX_URL: $PLEX_URL 76 | PLEX_TOKEN: $PLEX_TOKEN 77 | # perform analysis of the media files after refresh. empty or unset to disable. default: empty 78 | ANALYZE_MEDIA: $ANALYZE_MEDIA 79 | SLEEP_INTERVAL: $SLEEP 80 | REFRESH_MEDIA: $REFRESH_MEDIA 81 | LOG_LEVEL: $LOG_LEVEL 82 | DIRECTORY_PROC_MODULE: $DIRECTORY_PROC_MODULE 83 | volumes: 84 | - ./customprocessor.py:/app/customprocessor.py # custom processing function 85 | ``` 86 | 87 | ## Custom directory name processors 88 | 89 | Custom name processors are functions that allow you to transform the show/movie directory into something searchable in Plex. By default there is already a processor installed that covers the basic radarr and sonarr naming convention for directories like: _Movie Name (year)_. If you have this configuration **you don't need to implement your own processor** 90 | 91 | If you have your own naming convention for radarr/sonarr directories you **might** want to implement your own processor. For this you'll need to set the environment variable **DIRECTORY_PROC_MODULE** with your custom processor module name like **"customprocessor"**. 92 | 93 | The processor must implement two functions as follows: 94 | 95 | ```python 96 | def preprocess_movie_directory(name: str): 97 | """ implement this function returning the processed directory name """ 98 | return name 99 | ``` 100 | 101 | ```python 102 | def preprocess_show_directory(name:str): 103 | """ implement this function returning the processed directory name """ 104 | return name 105 | ``` 106 | 107 | #### Why should I implement a processor 108 | 109 | You are not obligated to implement your processor **even if you have a non-standard naming convention in radarr/sonarr**. Implementing a processor is an advanced feature to improve performance, since for **very big libraries (1000ish elements or more)** gathering all the library elements could take **as long as 30 seconds**. In comparison, using library search will take you less than **1 second** 110 | 111 | The processor is meant to convert a non-standard movie name to a searchable movie name e.g. `Blue.Beetle_(2018)` into `Blue Beetle`. -------------------------------------------------------------------------------- /api.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import os 3 | import time 4 | 5 | from flask import Blueprint 6 | from flask import jsonify, request, current_app 7 | from plexapihandler import PlexApiHandler 8 | 9 | ENV_ANALYZE_MEDIA = os.getenv("ANALYZE_MEDIA", "") 10 | ENV_REFRESH_MEDIA = os.getenv("REFRESH_MEDIA", "true") 11 | ENV_DIRECTORY_PROC_MODULE = os.getenv("DIRECTORY_PROC_MODULE", "nameprocessor") 12 | # Import the module dynamically 13 | nameprocessor_module = importlib.import_module(ENV_DIRECTORY_PROC_MODULE) 14 | main_bp = Blueprint("main", __name__) 15 | sleep = int(os.getenv("SLEEP_INTERVAL", "0")) 16 | plex_api = PlexApiHandler( 17 | os.getenv("PLEX_URL"), 18 | os.getenv("PLEX_TOKEN"), 19 | nameprocessor_module.preprocess_movie_directory, 20 | nameprocessor_module.preprocess_show_directory, 21 | ) 22 | 23 | 24 | @main_bp.route("/") 25 | def ping(): 26 | return "Ping successful" 27 | 28 | 29 | @main_bp.route("/triggers/manual", methods=["HEAD"]) 30 | def ok(): 31 | return "Ok" 32 | 33 | 34 | @main_bp.route("/triggers/manual", methods=["POST", "GET"]) 35 | def trigger(): 36 | directories = request.args.getlist("dir") 37 | 38 | if sleep: 39 | time.sleep(sleep) 40 | 41 | current_app.logger.warning("Starting directory scan of: {}".format(directories)) 42 | 43 | metadata_entries = [] 44 | 45 | if directories: 46 | for directory in directories: 47 | metadata_files = plex_api.find_metadata_from_dirs(directory=directory) 48 | files_refreshed = plex_api.refresh_metadata( 49 | metadata_files, ENV_ANALYZE_MEDIA, ENV_REFRESH_MEDIA 50 | ) 51 | metadata_entries.extend(files_refreshed) 52 | 53 | return jsonify(metadata_entries=metadata_entries) 54 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import Flask 3 | 4 | 5 | ENV_LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") 6 | 7 | 8 | def create_app(): 9 | app = Flask(__name__) 10 | app.logger.setLevel(ENV_LOG_LEVEL) 11 | # Import and register blueprints 12 | # Import the module dynamically 13 | from api import main_bp 14 | 15 | app.register_blueprint(main_bp) 16 | return app 17 | 18 | 19 | if __name__ == "__main__": 20 | create_app().run() 21 | -------------------------------------------------------------------------------- /customprocessor.py: -------------------------------------------------------------------------------- 1 | """this is just an example for a custom name processor""" 2 | 3 | 4 | def preprocess_movie_directory(name: str): 5 | """process movie directory by replacing all dots and underscores with spaces 6 | 7 | Args: 8 | name (str): movie directory path 9 | """ 10 | name = name.replace(".", " ").replace("_", " ") 11 | parts = name.split(" ") 12 | date_maybe = parts[-1] 13 | if date_maybe.startswith("(") and date_maybe.endswith(")"): 14 | return name.replace(date_maybe, "").rstrip() 15 | 16 | 17 | def preprocess_show_directory(name: str): 18 | """this function receives a directory and returns the show name to be searched 19 | If you have radarr/sonarr file naming configured as default, leave it as is.""" 20 | parts = name.split(" ") 21 | date_maybe = parts[-1] 22 | if date_maybe.startswith("(") and date_maybe.endswith(")"): 23 | return name.replace(date_maybe, "").rstrip() 24 | -------------------------------------------------------------------------------- /nameprocessor.py: -------------------------------------------------------------------------------- 1 | def preprocess_movie_directory(name: str): 2 | """this function receives a directory and returns the movie name to be searched 3 | If you have radarr/sonarr file naming configured as default, leave it as is.""" 4 | parts = name.split(" ") 5 | date_maybe = parts[-1] 6 | if date_maybe.startswith("(") and date_maybe.endswith(")"): 7 | return name.replace(date_maybe, "").rstrip() 8 | 9 | 10 | def preprocess_show_directory(name: str): 11 | """this function receives a directory and returns the show name to be searched 12 | If you have radarr/sonarr file naming configured as default, leave it as is.""" 13 | parts = name.split(" ") 14 | date_maybe = parts[-1] 15 | if date_maybe.startswith("(") and date_maybe.endswith(")"): 16 | return name.replace(date_maybe, "").rstrip() 17 | -------------------------------------------------------------------------------- /plexapihandler.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from plexapi.server import PlexServer 3 | from plexapi.library import ShowSection, MovieSection 4 | from flask import current_app 5 | 6 | 7 | class PlexApiHandler(object): 8 | def __init__( 9 | self, baseurl, token, moviename_processor_function, showname_processor_function 10 | ): 11 | """Plex API Handler functions 12 | 13 | Args: 14 | baseurl (_type_): The base URL to the PLEX instance. Schema should be included (http:// or https://) 15 | token (_type_): The PLEX instance token 16 | moviename_processor_function (func): the movie name processing function. By default matches the radarr standard config 17 | showname_processor_function (func): the show name processing function. By default matches the sonarr standard config 18 | """ 19 | self.baseurl = baseurl 20 | self.plex = PlexServer(self.baseurl, token) 21 | self.moviename_processor = moviename_processor_function 22 | self.showname_processor = showname_processor_function 23 | 24 | def find_section_from_dirs(self, directory): 25 | sections = self.plex.library.sections() 26 | 27 | for section in sections: 28 | for location in section.locations: 29 | if directory.startswith(location): 30 | return section, location 31 | 32 | def find_metadata_from_dirs(self, directory): 33 | """finds all the metadata elements from directories""" 34 | 35 | result = self.find_section_from_dirs(directory) 36 | if result: 37 | section, location = result 38 | section_parts_len = len(pathlib.PurePath(location).parts) 39 | directory_parts = pathlib.PurePath(directory).parts 40 | media_name = ( 41 | pathlib.PurePath(directory).parts[section_parts_len] 42 | if section_parts_len < len(directory_parts) 43 | else "" 44 | ) 45 | 46 | if isinstance(section, MovieSection): 47 | return self.process_movies(section, directory, media_name) 48 | elif isinstance(section, ShowSection): 49 | return self.process_shows(section, directory, media_name) 50 | 51 | def process_shows(self, section: ShowSection, directory, show_name): 52 | show_name_preprocessed = self.showname_processor(show_name) 53 | show_titles = "{},{}".format(show_name, show_name_preprocessed) 54 | library = section.searchShows(title=show_titles) or section.all() 55 | 56 | result_set = [] 57 | 58 | for element in library: 59 | for episode in element.episodes(): 60 | for part in episode.iterParts(): 61 | if part.file.startswith(directory): 62 | result_set.append(episode) 63 | 64 | return result_set 65 | 66 | def process_movies(self, section: MovieSection, directory, movie_name): 67 | movie_name_without_date = self.moviename_processor(movie_name) 68 | movie_titles = "{},{}".format(movie_name, movie_name_without_date) 69 | library = section.searchMovies(title=movie_titles) or section.all() 70 | result_set = [] 71 | 72 | for element in library: 73 | for part in element.iterParts(): 74 | if part.file.startswith(directory): 75 | result_set.append(element) 76 | 77 | return result_set 78 | 79 | def refresh_metadata(self, metadata_files, also_analyze="", also_refresh="true"): 80 | files_refreshed = [] 81 | 82 | if metadata_files: 83 | for element in metadata_files: 84 | if also_refresh: 85 | current_app.logger.debug( 86 | f"Refreshing metadata of : {element.title}" 87 | ) 88 | element.refresh() 89 | if also_analyze: 90 | current_app.logger.debug(f"Analyzing element : {element.title}") 91 | element.analyze() 92 | files_refreshed.append(element.title) 93 | 94 | return files_refreshed 95 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==2.2.2 2 | Werkzeug==2.2.2 3 | PlexAPI==4.13.1 4 | python-dotenv 5 | -------------------------------------------------------------------------------- /tests/app_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import unittest 4 | from dotenv import load_dotenv 5 | from app import create_app 6 | 7 | 8 | class AppTest(unittest.TestCase): 9 | def setUp(self): 10 | load_dotenv() 11 | self.app = create_app() 12 | self.client = self.app.test_client() 13 | self.base_url = "http://127.0.0.1" 14 | 15 | def test_ping(self): 16 | response = self.client.head( 17 | "/triggers/manual", base_url=self.base_url, content_type="application/json" 18 | ) 19 | 20 | self.assertEquals(response.status_code, 200) 21 | 22 | def test_process_triggers(self): 23 | response = self.client.post( 24 | "/triggers/manual", 25 | query_string={"dir": ["/test/test1", "/test/test2"]}, 26 | base_url=self.base_url, 27 | content_type="application/json", 28 | ) 29 | 30 | self.assertEquals(response.status_code, 200) 31 | 32 | print(json.loads(response.data)) 33 | 34 | def test_process_triggers_no_subdirectory(self): 35 | media_directory_no_subdirs = os.getenv("TEST_DIRECTORY", "/test/testdir") 36 | response = self.client.post( 37 | "/triggers/manual", 38 | query_string={"dir": [media_directory_no_subdirs]}, 39 | base_url=self.base_url, 40 | content_type="application/json", 41 | ) 42 | 43 | self.assertEquals(response.status_code, 200) 44 | 45 | print(json.loads(response.data)) 46 | 47 | def test_process_triggers_with_subdirectory(self): 48 | media_directory = os.getenv("TEST_DIRECTORY_SUBDIR", "/test/testdir") 49 | response = self.client.post( 50 | "/triggers/manual", 51 | query_string={"dir": [media_directory]}, 52 | base_url=self.base_url, 53 | content_type="application/json", 54 | ) 55 | 56 | self.assertEquals(response.status_code, 200) 57 | 58 | print(json.loads(response.data)) 59 | -------------------------------------------------------------------------------- /tests/processor_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import unittest 4 | 5 | from dotenv import load_dotenv 6 | 7 | from app import create_app 8 | 9 | 10 | class ProcessorTest(unittest.TestCase): 11 | def setUp(self): 12 | load_dotenv() 13 | load_dotenv(".processortest.env") 14 | self.app = create_app() 15 | self.client = self.app.test_client() 16 | self.base_url = "http://127.0.0.1" 17 | 18 | def test_custom_moviename_processor(self): 19 | media_directory = os.getenv("TEST_DIRECTORY_SUBDIR", "/test/testdir") 20 | response = self.client.post( 21 | "/triggers/manual", 22 | query_string={"dir": [media_directory]}, 23 | base_url=self.base_url, 24 | content_type="application/json", 25 | ) 26 | 27 | self.assertEquals(response.status_code, 200) 28 | 29 | print(json.loads(response.data)) 30 | --------------------------------------------------------------------------------