Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save HStep20/d6c5350bbcc12e40b1c9cdf7d9178c16 to your computer and use it in GitHub Desktop.
Save HStep20/d6c5350bbcc12e40b1c9cdf7d9178c16 to your computer and use it in GitHub Desktop.
This script will read your Overseer data and create/apply user tags to all of your sonarr/radarr instances
"""
This script will read your Overseer data and create/apply user tags to all of your sonarr/radarr instances, then create a filter in each connected -arr application for the users you specify.
It is forward compatible with the future User Tagging feature of overseer, and formats the tag in the same 'id - lowercase username' pattern Overseer will
It only uses built in python libraries, so you should be able to download and run without much hassle.
NOTE: YOU ARE REQUIRED TO USE IP:PORT CONNECTIONS FOR YOUR SONARR/RADARR INSTANCES INSIDE OF OVERSEERR
This will NOT utilize docker-compose style hostnames at the moment, and I don't use them personally, so I don't see myself adding them
Steps to use:
1. Add your Overseer API key
2. Add your Overseer Internal URL (might work with external domain url, but I didn't test)
2.5 Edit the Default values for number of users/requests
3. Press Play and wait
"""
import requests
from requests import HTTPError
from typing import Any
import re
import logging
from requests.models import Response
from urllib3.util import Retry
OVERSEER_API_KEY = "YOURAPIKEY"
OVERSEER_URL = "http://YOURIP:PORT/api/v1"
# I didn't want to figure out Pagination, so I set defaults to what I felt would be the maximum someone could have.
# If you have more than 100 users, or a user has more than 1000 requests, you'll need to update these values to reflect that
NUM_USERS_TO_PULL = 100
NUM_MAX_USER_REQUESTS = 1000
def handle_response(response: Response, *args: Any, **kwargs: Any) -> None:
"""Handles the Response and throws an error if there is an error with the request
Args:
response (Response): The response of the call being made
Raises:
requests.exceptions.HTTPError: Error raised by API
"""
try:
response.raise_for_status()
except requests.exceptions.HTTPError as e:
logger.error(
f"{response.status_code} - {response.request.url} - {response.text}"
)
raise requests.exceptions.HTTPError(f"{str(e)}: {response.text}") from e
def make_session(api_key: str) -> requests.Session:
"""Creates a Requests Session with headers and logging set up
Args:
api_key (str): API key of service being accessed with the session
Returns:
requests.Session: Requests session with overhead added
"""
session = requests.Session()
session.hooks["response"] = [handle_response]
adapter = requests.adapters.HTTPAdapter(
max_retries=Retry(
total=10,
backoff_factor=5,
status_forcelist=[429, 500],
allowed_methods=["GET", "POST", "PUT"],
)
)
session.mount("https://", adapter)
session.headers.update({"X-Api-Key": api_key})
return session
def map_arr_api_keys() -> dict[str, str]:
"""Gets all sonarr/radarr servers + api keys from Overseer and returns a map of them
Returns:
dict[str,str]: A Map of -arr server_urls : api_keys
"""
requests_session = make_session(api_key=OVERSEER_API_KEY)
sonarr_servers = requests_session.get(url=OVERSEER_URL + "/settings/sonarr").json()
radarr_servers = requests_session.get(url=OVERSEER_URL + "/settings/radarr").json()
all_servers = sonarr_servers + radarr_servers
api_key_map = {}
for server in all_servers:
api_key_map[server["hostname"] + ":" + str(server["port"])] = server["apiKey"]
return api_key_map
def tag_requests_from_user(
arr_api_key_map: dict[str, str],
user_requests: dict[str, Any],
user_tag_string: str,
) -> None:
"""Tags all the requests for each user
Args:
arr_api_key_map (dict[str, str]): Map of the server URL and API key for each service connected to overseer
user_requests (dict[str, Any]): list of user requests
user_tag_string (str): Formatted user tag name. Follows "id - lowercase username" format
"""
for media_request in user_requests:
try:
tag_request_from_user(
media_request=media_request,
arr_api_key_map=arr_api_key_map,
user_tag_string=user_tag_string,
)
except ValueError as e:
logger.error(e)
def tag_request_from_user(
media_request: dict[str, Any], arr_api_key_map: dict[str, str], user_tag_string: str
):
"""Reads request data from Overseer, and finds the media within Sonarr/Radarr, and applies a user tag to that item in its respective server
Args:
media_request (dict[str, Any]): The Media Request metadata provided by Overseerr API
arr_api_key_map (dict[str, str]): Map of all servers connected to Overseerr, and their API keys
user_tag_string (str): Formatted user tag name. Follows "id - lowercase username" format
"""
if media_request["status"] == 4:
raise ValueError(
f"{arr_object_data['title']} has ERROR request status - Skipping"
)
if "serviceUrl" not in media_request["media"]:
raise ValueError(
f"{arr_object_data['title']} has no ServiceURL associated with it - Skipping"
)
# Unfortunately the provided service URL doesn't include the /v3/api slug, so we have to build our own
non_api_url = media_request["media"]["serviceUrl"]
ip_port = re.findall(r"[0-9]+(?:\.[0-9]+){3}:[0-9]+", non_api_url)[0]
base_url = "http://" + ip_port
service_url = base_url + "/api/v3"
if media_request["type"] == "tv":
request_path = "/series"
request_params = {"tvdbId": media_request["media"]["tvdbId"]}
else:
request_path = "/movie"
request_params = {"tmdbId": media_request["media"]["tmdbId"]}
requests_session = make_session(api_key=arr_api_key_map[ip_port])
arr_object_data = requests_session.get(
url=service_url + request_path,
params=request_params,
).json()
if len(arr_object_data) == 0:
raise ValueError(
f"{base_url} - {media_request['media']['externalServiceSlug']} is in the user's request list, but not found on server - Skipping"
)
arr_object_data = arr_object_data[0]
tag_data = requests_session.get(
url=service_url + "/tag",
).json()
# Because each request has its own server associated with it, we should check for the tag each time.
# The alternate way would be to group by server, then do one check per server, but we don't need to worry about api calls here
tag_id = get_tag_id(tag_data, user_tag_string)
if tag_id == -1:
logger.warning(f'{base_url} - Tag "{user_tag_string}" not found in server.')
tag_creation_response = create_user_tag(
requests_session=requests_session,
service_url=service_url,
user_tag_string=user_tag_string,
)
if tag_creation_response.ok:
tag_id = tag_creation_response.json()["id"]
logger.info(f"{base_url} - Created tag {user_tag_string} with id: {tag_id}")
else:
raise HTTPError(f'{base_url} - Failed to create tag "{user_tag_string}"')
if tag_id in arr_object_data["tags"]:
logger.info(
f"{base_url} - {user_tag_string} - {arr_object_data['title']} already has user tag"
)
else:
tag_addition_response = tag_media_with_user_data(
requests_session=requests_session,
service_url=service_url,
request_path=request_path,
request_params=request_params,
arr_object_data=arr_object_data,
tag_id=tag_id,
)
if tag_addition_response.ok:
logger.info(
f"{base_url} - {user_tag_string} - Tagged {arr_object_data['title']}"
)
else:
raise HTTPError(tag_addition_response.text)
def get_tag_id(tag_data: dict[str, Any], user_tag_string: str) -> int:
"""Gets the tagId of the user's tag from the respective server.
Args:
tag_data (dict[str, Any]): The Tag Data from the -arr api
user_tag_string (str): The tag name for the current overseer user
Returns:
int: The tagId of the respective -arr instance. Returns -1 if it doesn't exist
"""
for tag in tag_data:
if tag["label"] == user_tag_string:
return tag["id"]
return -1
def create_user_tag(
requests_session: requests.Session,
service_url: str,
user_tag_string: str,
) -> dict[str, Any]:
"""Create a user tag in Sonarr/Radarr
Args:
requests_session (requests.Session): Requests session for app you are creating tag in
service_url (str): the URL of the app you are creating the tag in
user_tag_string (str): tag string, which will be the tag name
Returns:
dict[str, Any]: Tag creation return data, including new ID
"""
return requests_session.post(
url=service_url + "/tag",
json={"label": user_tag_string},
)
def tag_media_with_user_data(
requests_session: requests.Session,
service_url: str,
request_path: str,
request_params: dict[str, Any],
arr_object_data: dict[str, Any],
tag_id: int,
) -> requests.Response:
"""Applies tag to selected media object
Args:
requests_session (requests.Session): Requests session for app you are apply tag in
service_url (str): URL of app
request_path (str): Slug to interact with media object
request_params (dict[str, Any]): Extra request params to dictate the media object
arr_object_data (dict[str, Any]): Media Object metadata from Sonarr/Radarr
tag_id (int): Tag ID to apply to arr_object_data
Returns:
requests.Response: Response from tag call
"""
if tag_id not in arr_object_data["tags"]:
arr_object_data["tags"].append(tag_id)
return requests_session.put(
url=service_url + request_path,
params=request_params,
json=arr_object_data,
)
def create_tag_filter_in_application(
arr_api_key_map: dict[str, str], user_tag_string: str
):
"""Create a custom filter in each server for the user tag
Args:
arr_api_key_map (dict[str, str]): Map of -arr URLs:API Keys
user_tag_string (str): Tag Name for the current user
"""
for server in arr_api_key_map:
base_url = "http://" + server + "/api/v3"
requests_session = make_session(api_key=arr_api_key_map[server])
current_filters = requests_session.get(url=base_url + "/customfilter").json()
current_filter_labels = [x["label"] for x in current_filters]
if user_tag_string not in current_filter_labels:
tag_info = requests_session.get(url=base_url + "/tag").json()
tag_id = get_tag_id(tag_data=tag_info, user_tag_string=user_tag_string)
server_info = requests_session.get(url=base_url + "/system/status").json()
if server_info["appName"].lower() == "sonarr":
filter_type = "series"
else:
filter_type = "movieIndex"
sonarr_filter = {
"type": filter_type,
"label": user_tag_string,
"filters": [{"key": "tags", "value": [tag_id], "type": "contains"}],
}
requests_session.post(url=base_url + "/customfilter", json=sonarr_filter)
logger.info(f"http://{server} - {user_tag_string} - Created Filter")
else:
logger.warning(
f"http://{server} - {user_tag_string} - Filter Already Exists - Skipping"
)
def main():
arr_api_key_map = map_arr_api_keys()
overseer_requests_session = make_session(api_key=OVERSEER_API_KEY)
all_users = overseer_requests_session.get(
url=OVERSEER_URL + "/user", params={"take": NUM_USERS_TO_PULL}
).json()["results"]
for user in all_users:
user_data = overseer_requests_session.get(
url=OVERSEER_URL + f"/user/{user['id']}"
).json()
# My users don't have a ton of requests, so I didn't want to bother figuring out pagination.
# This should just pull all requests (unless you have users who request A TON)
user_requests = overseer_requests_session.get(
url=OVERSEER_URL + f"/user/{user['id']}/requests",
params={"take": NUM_MAX_USER_REQUESTS},
).json()["results"]
user_tag_string = (
str(user_data["id"]) + " - " + user_data["displayName"].lower()
)
separator = "\n==============================================\n"
print(
separator
+ f" Tagging {user_data['displayName']}'s Media"
+ separator
)
if len(user_requests) > 0:
tag_requests_from_user(arr_api_key_map, user_requests, user_tag_string)
create_tag_filter_in_application(arr_api_key_map, user_tag_string)
else:
logger.warning(f"{user['displayName']} has no requests - Skipping")
if __name__ == "__main__":
# create logger with 'spam_application'
logger = logging.getLogger("overseer_tagger")
logger.setLevel(logging.INFO)
fh = logging.StreamHandler()
fh.setLevel(logging.DEBUG)
ch = logging.StreamHandler()
ch.setLevel(logging.ERROR)
# create formatter and add it to the handlers
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
fh.setFormatter(formatter)
ch.setFormatter(formatter)
# add the handlers to the logger
logger.addHandler(fh)
logger.addHandler(ch)
main()
@jakereed1
Copy link

As far as I know everything is up to date, Plex is on the latest version, Overseerr on the latest version. I have run all the scheduled tasks in Plex and Overseerr. No worries, I will wait for Overseerr to release tagging and just have tags going forwards :). Thanks anyways.

@dlabrie
Copy link

dlabrie commented Jun 6, 2023

I'm using a private reverse proxy with https for my sonarr/radarr services.

I updated line 142-3 to this as well as replacing all http: instances to https: and it appears to work.

ip_port = re.findall(r"https?\:\/\/([a-zA-Z0-9\.]+:[0-9]+)?", non_api_url)[0] base_url = "https://" + ip_port

@HStep20
Copy link
Author

HStep20 commented Jun 6, 2023

Thanks for the work on that! Thats pretty easy to do, so I can add it in there and use it as a fallback check if no IP match is found.

The unsupported aspect will definitely be using something like docker-compose labels, as the script wouldn't be running in the docker network to talk to the hostnames directly.

@danshilm
Copy link

Just came across this and wanted to say that this is awesome! Thanks a lot for the work to make this @HStep20, I know it's something that users will find useful.
If you need any help/clarifications/insight with anything on Overseerr's side, feel free to tag me on Discord in the Overseerr server 😃

@NeeWii
Copy link

NeeWii commented Jul 17, 2023

Getting the same "out of range" error. Python version is 3.10.6. Overseerr is v1.33.0, and is configured to use the LAN IP (192.168...) and port combo for each service. I have overseerr set up with two movies instances (one normal, one 4k) and similar for TV. Would appreciate any help on how to troubleshoot, I'm very much a novice!

Traceback (most recent call last):
File "/home/username/Downloads/tag_sonarr_radarr_media_with_overseer_users.py", line 362, in
main()
File "/home/username/Downloads/tag_sonarr_radarr_media_with_overseer_users.py", line 338, in main
tag_requests_from_user(arr_api_key_map, user_requests, user_tag_string)
File "/home/username/Downloads/tag_sonarr_radarr_media_with_overseer_users.py", line 112, in tag_requests_from_user
tag_request_from_user(
File "/home/username/Downloads/tag_sonarr_radarr_media_with_overseer_users.py", line 142, in tag_request_from_user
ip_port = re.findall(r"[0-9]+(?:.[0-9]+){3}:[0-9]+", non_api_url)[0]
IndexError: list index out of range

@goodyear77
Copy link

goodyear77 commented Aug 13, 2023

EDIT: Nevermind, figured it out, leaving for others having the same issue. Just install the module request with the following command and the script then works:

# sudo pip3 install requests


Great tool! I've installed python 3.10 on my macOS Ventura, but when I try to run the script I get the following error:

# cd '' && '/usr/local/bin/python3'  '/Users/dwight/Downloads/overseerr_tags.py'  && echo Exit status: $? && exit 1
Traceback (most recent call last):
  File "/Users/dwight/Downloads/overseerr_tags.py", line 2, in <module>
    import requests
ModuleNotFoundError: No module named 'requests'

Same thing if I just run it from the directory where I've placed the script:

# python3 overseerr_tags.py 
Traceback (most recent call last):
  File "/Users/dwight/Downloads/overseerr_tags.py", line 2, in <module>
    import requests
ModuleNotFoundError: No module named 'requests'

Probably a super noob issue, any help would be appreciated!

@TheChrisK
Copy link

TheChrisK commented Feb 7, 2024

Getting the same "out of range" error. Python version is 3.10.6. Overseerr is v1.33.0, and is configured to use the LAN IP (192.168...) and port combo for each service. I have overseerr set up with two movies instances (one normal, one 4k) and similar for TV. Would appreciate any help on how to troubleshoot, I'm very much a novice!

Traceback (most recent call last):
File "/home/username/Downloads/tag_sonarr_radarr_media_with_overseer_users.py", line 362, in
main()
File "/home/username/Downloads/tag_sonarr_radarr_media_with_overseer_users.py", line 338, in main
tag_requests_from_user(arr_api_key_map, user_requests, user_tag_string)
File "/home/username/Downloads/tag_sonarr_radarr_media_with_overseer_users.py", line 112, in tag_requests_from_user
tag_request_from_user(
File "/home/username/Downloads/tag_sonarr_radarr_media_with_overseer_users.py", line 142, in tag_request_from_user
ip_port = re.findall(r"[0-9]+(?:.[0-9]+){3}:[0-9]+", non_api_url)[0]
IndexError: list index out of range

I am seeing the same error. I know this is old now and maybe out of date with Overseerr.

Edit: I see the issue is the "External URL" must be the IP and port. I thought it was the service URL. That fixed it and ran successfully. Thanks!

@mattague
Copy link

mattague commented Feb 9, 2024

I am having slightly different issue that I can't quite figure out

Traceback (most recent call last):
  File "/home/mattague/docker/tag.py", line 344, in <module>
    main()
  File "/home/mattague/docker/tag.py", line 320, in main
    tag_requests_from_user(arr_api_key_map, user_requests, user_tag_string)
  File "/home/mattague/docker/tag.py", line 102, in tag_requests_from_user
    tag_request_from_user(
  File "/home/mattague/docker/tag.py", line 167, in tag_request_from_user
    tag_id = tag_creation_response.json()["id"]
TypeError: list indices must be integers or slices, not str

@johnistheman
Copy link

I added some logging but I can't figure out why it's not working...

After changing the URLs in Overseerr to use IPs, it's able to connect to Overseerr/Sonarr/Radarr, but it fails to create tags in Radarr/Sonarr for some reason.

If I manually create the tags in Sonarr/Radarr, it doesn't fail, it carrys on as if it worked, but no tags are actually added to media in Sonarr/Radarr. It will return tags that are on the media after "adding" the new tag, but it still shows only the old tags (or no tags).

@PierreDurrr
Copy link

PierreDurrr commented Apr 24, 2024

And a different one here :

Traceback (most recent call last):
  File "/config/scripts/tag.py", line 362, in <module>
    main()
  File "/config/scripts/tag.py", line 338, in main
    tag_requests_from_user(arr_api_key_map, user_requests, user_tag_string)
  File "/config/scripts/tag.py", line 112, in tag_requests_from_user
    tag_request_from_user(
  File "/config/scripts/tag.py", line 133, in tag_request_from_user
    f"{arr_object_data['title']} has ERROR request status - Skipping"
UnboundLocalError: local variable 'arr_object_data' referenced before assignment```

@Shomesomesho
Copy link

Shomesomesho commented May 4, 2024

Trying to run this on Jellyseer. It successfully tags items for a couple users but then throws this error.

  File "/mnt/user/appdata/scripts/jellyseer.py", line 344, in <module>
    main()
  File "/mnt/user/appdata/scripts/jellyseer.py", line 320, in main
    tag_requests_from_user(arr_api_key_map, user_requests, user_tag_string)
  File "/mnt/user/appdata/scripts/jellyseer.py", line 102, in tag_requests_from_user
    tag_request_from_user(
  File "/mnt/user/appdata/scripts/jellyseer.py", line 126, in tag_request_from_user
    f"{arr_object_data['title']} has no ServiceURL associated with it - Skipping"
UnboundLocalError: local variable 'arr_object_data' referenced before assignment`

@jgramling17
Copy link

And a different one here :

Traceback (most recent call last):
  File "/config/scripts/tag.py", line 362, in <module>
    main()
  File "/config/scripts/tag.py", line 338, in main
    tag_requests_from_user(arr_api_key_map, user_requests, user_tag_string)
  File "/config/scripts/tag.py", line 112, in tag_requests_from_user
    tag_request_from_user(
  File "/config/scripts/tag.py", line 133, in tag_request_from_user
    f"{arr_object_data['title']} has ERROR request status - Skipping"
UnboundLocalError: local variable 'arr_object_data' referenced before assignment```

I found that this happens with the following types of requests.

  • Failed
  • Declined
  • Pending

This is probably because it's expecting every 'request' to be in sonarr/radarr and it's not when it's in within one of these statuses ^
Clean up your requests and re-run and this should fix.

@Shomesomesho
Copy link

Shomesomesho commented May 10, 2024

Appreciate the tip, but all I have for statuses on all requests are either "Requested" or "Available" and I still encounter this error.

Edit: I figured out my specific issue. I use Jellyseer and 2 instances of Sonarr. One for 1080p & one for 4K. Same situation for Radarr. The 4K requests were causing my errors. Removing those requests from Jellyseer allows the script to run to completion.

@crodgers89
Copy link

TL;DR version:
Is there a way to modify the script to only run for a specific user? (single user run) That or run the script for all except for a specific user? (multi-user with exclusions) I've narrowed it down to a specific user and cleaning up all his requests didn't fix it, but if I could skip him I think I'd get the majority of the rest to run and just manually fix his later.

Long Version:
I was getting the UnboundLocalError: local variable 'arr_object_data' referenced before assignment error too and the script would stop dead following the first request that triggered the error. I managed to get farther by adding global arr_object_data just before line 131, this didn't stop from getting an error for the first request still in "pending" status but it did make it so the script would at least skip to process the next user instead of stopping dead.

global arr_object_data #added
if media_request["status"] == 4:
    raise ValueError(
        f"{arr_object_data['title']} has ERROR request status - Skipping"
    )
if "serviceUrl" not in media_request["media"]:
    raise ValueError(
        f"{arr_object_data['title']} has no ServiceURL associated with it - Skipping"
    )

However now I'm getting a new error with the specific user where I cleaned up all the remaining requests by either accepting the pending requests or deleting the declined/failed requests. All his requests are now either Pending or Available. I started getting TypeError: argument of type 'NoneType' is not iterable and once again the script stops dead in it's tracks. If I shorten the max requests in line 35 to 11 requests it won't encounter the error and will continue to subsequent users but only processes the first 11 of theirs. If I increase it to 12 that 12th request is what errors out and kills the script which sucks 'cause he's only the 3rd user of 8.

Traceback (most recent call last):
  File "/docker_comp/tag_sonarr_radarr_media_with_overseer_users.py", line 363, in <module>
    main()
  File "/docker_comp/tag_sonarr_radarr_media_with_overseer_users.py", line 339, in main
    tag_requests_from_user(arr_api_key_map, user_requests, user_tag_string)
  File "/docker_comp/tag_sonarr_radarr_media_with_overseer_users.py", line 112, in tag_requests_from_user
    tag_request_from_user(
  File "/docker_comp/tag_sonarr_radarr_media_with_overseer_users.py", line 135, in tag_request_from_user
    if "serviceUrl" not in media_request["media"]:
TypeError: argument of type 'NoneType' is not iterable

@northirid
Copy link

northirid commented Jul 13, 2024

I'm late to the party, I know, but I'm getting an index out of range error similar to others.

==============================================
         Tagging northirid's Media
==============================================

Traceback (most recent call last):
  File "c:\Users\matt\OneDrive\Documents\Scripts\Python\OverseerTagger.py", line 362, in <module>
    main()
  File "c:\Users\matt\OneDrive\Documents\Scripts\Python\OverseerTagger.py", line 338, in main
    tag_requests_from_user(arr_api_key_map, user_requests, user_tag_string)
  File "c:\Users\matt\OneDrive\Documents\Scripts\Python\OverseerTagger.py", line 112, in tag_requests_from_user
    tag_request_from_user(
  File "c:\Users\matt\OneDrive\Documents\Scripts\Python\OverseerTagger.py", line 142, in tag_request_from_user
    ip_port = re.findall(r"[0-9]+(?:\.[0-9]+){3}:[0-9]+", non_api_url)[0]
              ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^
IndexError: list index out of range

I currently have sonarr & radarr set up as IP:PORT in overseerr services, and set up overseerr with IP:PORT as the application URL. Still get that error though.

If I manually configure the ip_port (i.e. hardcode 192.168.x.x:8989) in there, it runs, but then errors out when it hits movies requests vs tv shows, as it's connecting to sonarr only.

Is there something that I can modify to have that list index work as intended?

@northirid
Copy link

I'm late to the party, I know, but I'm getting an index out of range error similar to others.

Is there something that I can modify to have that list index work as intended?

For anyone who comes after me:
The key thing to modify is in Overseerr -> Services -> Radarr/Sonarr -> external URL in that specific popup. That was the bit I was hung up on.

NGL, chatgpt helped by adding in this error handling.

    non_api_url = media_request["media"]["serviceUrl"]
    ip_port_matches = re.findall(r"[0-9]+(?:\.[0-9]+){3}:[0-9]+", non_api_url)
    if not ip_port_matches:
        raise ValueError(f"Service URL {non_api_url} does not contain a valid IP:PORT")
    ip_port = ip_port_matches[0]
    base_url = "http://" + ip_port
    service_url = base_url + "/api/v3"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment