Skip to content

Commit

Permalink
Merge pull request #9 from dantebarba/bugfix/fix-library-subdir-scanning
Browse files Browse the repository at this point in the history
+ If no subdir is present it scans the whole directory
+ New tests added
+ Refactored the name processor to allow different directory naming conventions
+ library search is mandatory since section.all() takes up to 20x times the section search
+ Added new movie and show processor module
+ Added new env var DIRECTORY_PROC_MODULE
+ Added new processing functions to module
+ Added new custom module as example
+ Dockerfile: included new module
+ Black run over all .py files
+ .gitignore updated with test .env
+ new processor_test added
+ README.md updated with new processor variable and description

closes #6
fixes #5
  • Loading branch information
dantebarba authored Feb 16, 2024
2 parents 6168651 + 43d0ddb commit e706052
Show file tree
Hide file tree
Showing 10 changed files with 170 additions and 33 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ celerybeat.pid
*.sage.py

# Environments
.env
*.env
.venv
env/
venv/
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ ENV ANALYZE_MEDIA ""
ENV REFRESH_MEDIA "true"
ENV LOG_LEVEL "INFO"
ENV SLEEP_INTERVAL "0"
ENV DIRECTORY_PROC_MODULE "nameprocessor"

CMD [ "python3", "-m" , "flask", "run", "--host=0.0.0.0"]
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ ANALYZE_MEDIA="" # perform analysis of the media files after refresh. Empty or u
REFRESH_MEDIA="true" # perform metadata refresh of the media files. Active by default.
SLEEP_INTERVAL="0" # wait before starting the scanning process after each request. default is 0 (disabled)
LOG_LEVEL="INFO" # the logging level for the application. Available values are DEBUG, INFO, WARNING, ERROR. Default is INFO
DIRECTORY_PROC_MODULE="nameprocessor" # directory name processor. Explained in more detail below
```

2. Run the python flask server
Expand Down Expand Up @@ -78,5 +79,33 @@ services:
SLEEP_INTERVAL: $SLEEP
REFRESH_MEDIA: $REFRESH_MEDIA
LOG_LEVEL: $LOG_LEVEL
DIRECTORY_PROC_MODULE: $DIRECTORY_PROC_MODULE
volumes:
- ./customprocessor.py:/app/customprocessor.py # custom processing function
```
## Custom directory name processors
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**
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"**.
The processor must implement two functions as follows:
```python
def preprocess_movie_directory(name: str):
""" implement this function returning the processed directory name """
return name
```
```python
def preprocess_show_directory(name:str):
""" implement this function returning the processed directory name """
return name
```
#### Why should I implement a processor
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**
The processor is meant to convert a non-standard movie name to a searchable movie name e.g. `Blue.Beetle_(2018)` into `Blue Beetle`.
24 changes: 18 additions & 6 deletions api.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import importlib
import os
import time

Expand All @@ -7,35 +8,46 @@

ENV_ANALYZE_MEDIA = os.getenv("ANALYZE_MEDIA", "")
ENV_REFRESH_MEDIA = os.getenv("REFRESH_MEDIA", "true")

main_bp = Blueprint('main', __name__)
ENV_DIRECTORY_PROC_MODULE = os.getenv("DIRECTORY_PROC_MODULE", "nameprocessor")
# Import the module dynamically
nameprocessor_module = importlib.import_module(ENV_DIRECTORY_PROC_MODULE)
main_bp = Blueprint("main", __name__)
sleep = int(os.getenv("SLEEP_INTERVAL", "0"))
plex_api = PlexApiHandler(os.getenv("PLEX_URL"), os.getenv("PLEX_TOKEN"))
plex_api = PlexApiHandler(
os.getenv("PLEX_URL"),
os.getenv("PLEX_TOKEN"),
nameprocessor_module.preprocess_movie_directory,
nameprocessor_module.preprocess_show_directory,
)


@main_bp.route("/")
def ping():
return "Ping successful"


@main_bp.route("/triggers/manual", methods=["HEAD"])
def ok():
return "Ok"


@main_bp.route("/triggers/manual", methods=["POST", "GET"])
def trigger():
directories = request.args.getlist("dir")

if sleep:
time.sleep(sleep)

current_app.logger.warning("Starting directory scan of: {}".format(directories))

metadata_entries = []

if directories:
for directory in directories:
metadata_files = plex_api.find_metadata_from_dirs(directory=directory)
files_refreshed = plex_api.refresh_metadata(metadata_files, ENV_ANALYZE_MEDIA, ENV_REFRESH_MEDIA)
files_refreshed = plex_api.refresh_metadata(
metadata_files, ENV_ANALYZE_MEDIA, ENV_REFRESH_MEDIA
)
metadata_entries.extend(files_refreshed)

return jsonify(metadata_entries=metadata_entries)
6 changes: 5 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@

ENV_LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")


def create_app():
app = Flask(__name__)
app.logger.setLevel(ENV_LOG_LEVEL)
# Import and register blueprints
# Import the module dynamically
from api import main_bp

app.register_blueprint(main_bp)
return app


if __name__ == "__main__":
create_app().run()
create_app().run()
23 changes: 23 additions & 0 deletions customprocessor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""this is just an example for a custom name processor"""


def preprocess_movie_directory(name: str):
"""process movie directory by replacing all dots and underscores with spaces
Args:
name (str): movie directory path
"""
name = name.replace(".", " ").replace("_", " ")
parts = name.split(" ")
date_maybe = parts[-1]
if date_maybe.startswith("(") and date_maybe.endswith(")"):
return name.replace(date_maybe, "").rstrip()


def preprocess_show_directory(name: str):
"""this function receives a directory and returns the show name to be searched
If you have radarr/sonarr file naming configured as default, leave it as is."""
parts = name.split(" ")
date_maybe = parts[-1]
if date_maybe.startswith("(") and date_maybe.endswith(")"):
return name.replace(date_maybe, "").rstrip()
16 changes: 16 additions & 0 deletions nameprocessor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
def preprocess_movie_directory(name: str):
"""this function receives a directory and returns the movie name to be searched
If you have radarr/sonarr file naming configured as default, leave it as is."""
parts = name.split(" ")
date_maybe = parts[-1]
if date_maybe.startswith("(") and date_maybe.endswith(")"):
return name.replace(date_maybe, "").rstrip()


def preprocess_show_directory(name: str):
"""this function receives a directory and returns the show name to be searched
If you have radarr/sonarr file naming configured as default, leave it as is."""
parts = name.split(" ")
date_maybe = parts[-1]
if date_maybe.startswith("(") and date_maybe.endswith(")"):
return name.replace(date_maybe, "").rstrip()
41 changes: 26 additions & 15 deletions plexapihandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,22 @@
from flask import current_app


def name_without_date(name):
parts = name.split(" ")
date_maybe = parts[-1]
if date_maybe.startswith("(") and date_maybe.endswith(")"):
return name.replace(date_maybe, "").rstrip()


class PlexApiHandler(object):
def __init__(self, baseurl, token):
def __init__(
self, baseurl, token, moviename_processor_function, showname_processor_function
):
"""Plex API Handler functions
Args:
baseurl (_type_): The base URL to the PLEX instance. Schema should be included (http:// or https://)
token (_type_): The PLEX instance token
moviename_processor_function (func): the movie name processing function. By default matches the radarr standard config
showname_processor_function (func): the show name processing function. By default matches the sonarr standard config
"""
self.baseurl = baseurl
self.plex = PlexServer(self.baseurl, token)
self.moviename_processor = moviename_processor_function
self.showname_processor = showname_processor_function

def find_section_from_dirs(self, directory):
sections = self.plex.library.sections()
Expand All @@ -30,17 +35,22 @@ def find_metadata_from_dirs(self, directory):
result = self.find_section_from_dirs(directory)
if result:
section, location = result
section_parts = len(pathlib.PurePath(location).parts)
media_name = pathlib.PurePath(directory).parts[section_parts]
section_parts_len = len(pathlib.PurePath(location).parts)
directory_parts = pathlib.PurePath(directory).parts
media_name = (
pathlib.PurePath(directory).parts[section_parts_len]
if section_parts_len < len(directory_parts)
else ""
)

if isinstance(section, MovieSection):
return self.process_movies(section, directory, media_name)
elif isinstance(section, ShowSection):
return self.process_shows(section, directory, media_name)

def process_shows(self, section: ShowSection, directory, show_name):
show_name_without_date = name_without_date(show_name)
show_titles = "{},{}".format(show_name, show_name_without_date)
show_name_preprocessed = self.showname_processor(show_name)
show_titles = "{},{}".format(show_name, show_name_preprocessed)
library = section.searchShows(title=show_titles) or section.all()

result_set = []
Expand All @@ -54,10 +64,9 @@ def process_shows(self, section: ShowSection, directory, show_name):
return result_set

def process_movies(self, section: MovieSection, directory, movie_name):
movie_name_without_date = name_without_date(movie_name)
movie_name_without_date = self.moviename_processor(movie_name)
movie_titles = "{},{}".format(movie_name, movie_name_without_date)
library = section.searchMovies(title=movie_titles) or section.all()

result_set = []

for element in library:
Expand All @@ -73,7 +82,9 @@ def refresh_metadata(self, metadata_files, also_analyze="", also_refresh="true")
if metadata_files:
for element in metadata_files:
if also_refresh:
current_app.logger.debug(f"Refreshing metadata of : {element.title}")
current_app.logger.debug(
f"Refreshing metadata of : {element.title}"
)
element.refresh()
if also_analyze:
current_app.logger.debug(f"Analyzing element : {element.title}")
Expand Down
32 changes: 22 additions & 10 deletions tests/app_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from dotenv import load_dotenv
from app import create_app


class AppTest(unittest.TestCase):
def setUp(self):
load_dotenv()
Expand All @@ -13,34 +14,45 @@ def setUp(self):

def test_ping(self):
response = self.client.head(
"/triggers/manual",
base_url=self.base_url,
content_type="application/json"
"/triggers/manual", base_url=self.base_url, content_type="application/json"
)

self.assertEquals(response.status_code, 200)

def test_process_triggers(self):
response = self.client.post(
"/triggers/manual",
query_string={"dir": ["/test/test1","/test/test2"]},
query_string={"dir": ["/test/test1", "/test/test2"]},
base_url=self.base_url,
content_type="application/json"
content_type="application/json",
)

self.assertEquals(response.status_code, 200)

print(json.loads(response.data))

def test_process_triggers_no_subdirectory(self):
media_directory_no_subdirs = os.getenv("TEST_DIRECTORY", "/test/testdir")
response = self.client.post(
"/triggers/manual",
query_string={"dir": [media_directory_no_subdirs]},
base_url=self.base_url,
content_type="application/json"
content_type="application/json",
)

self.assertEquals(response.status_code, 200)

print(json.loads(response.data))

def test_process_triggers_with_subdirectory(self):
media_directory = os.getenv("TEST_DIRECTORY_SUBDIR", "/test/testdir")
response = self.client.post(
"/triggers/manual",
query_string={"dir": [media_directory]},
base_url=self.base_url,
content_type="application/json",
)

self.assertEquals(response.status_code, 200)
print(json.loads(response.data))

print(json.loads(response.data))
29 changes: 29 additions & 0 deletions tests/processor_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import json
import os
import unittest

from dotenv import load_dotenv

from app import create_app


class ProcessorTest(unittest.TestCase):
def setUp(self):
load_dotenv()
load_dotenv(".processortest.env")
self.app = create_app()
self.client = self.app.test_client()
self.base_url = "http://127.0.0.1"

def test_custom_moviename_processor(self):
media_directory = os.getenv("TEST_DIRECTORY_SUBDIR", "/test/testdir")
response = self.client.post(
"/triggers/manual",
query_string={"dir": [media_directory]},
base_url=self.base_url,
content_type="application/json",
)

self.assertEquals(response.status_code, 200)

print(json.loads(response.data))

0 comments on commit e706052

Please sign in to comment.