347 lines
13 KiB
Python
Executable File
347 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3.10
|
|
|
|
import configparser
|
|
import logging
|
|
import traceback
|
|
import argparse
|
|
import json
|
|
import os
|
|
import ast
|
|
import sys
|
|
import requests
|
|
from datetime import datetime
|
|
from dateutil.relativedelta import relativedelta
|
|
import re
|
|
|
|
class RequestError(Exception):
|
|
pass
|
|
|
|
class ApplicationError(Exception):
|
|
pass
|
|
|
|
TAUTULLI_SERVER_URL = ""
|
|
TAUTULLI_API_KEY = ""
|
|
SONARR_SERVER_URL = ""
|
|
SONARR_API_KEY = ""
|
|
RADARR_SERVER_URL = ""
|
|
RADARR_API_KEY = ""
|
|
FILM_SECTION_IDS = []
|
|
DEADLINE_NEVER_WATCHED = ""
|
|
DEADLINE_LAST_WATCHED = ""
|
|
FILES_TO_KEEP = []
|
|
|
|
# Retrieve the list of records that does not follow the deadline policies from tautulli
|
|
def get_unwatched_rating_keys(sectionId):
|
|
# Init variables
|
|
length = 100
|
|
start = 0
|
|
unwatched = []
|
|
deadlineNeverWacthed = datetime.now() - relativedelta(months=DEADLINE_NEVER_WATCHED)
|
|
deadlineLastWacthed = datetime.now() - relativedelta(months=DEADLINE_LAST_WATCHED)
|
|
|
|
# Send http request to refresh the list and get the number of media to safeguard from infinite loop
|
|
payload = {'apikey': TAUTULLI_API_KEY, 'cmd': 'get_library_media_info', 'section_id': sectionId, 'order_column': 'last_played', 'order_dir': 'asc','length': 1, 'start': 0, 'refresh ': 'true'}
|
|
request = requests.get(TAUTULLI_SERVER_URL+"/api/v2", params=payload)
|
|
response = json.loads(request.content)
|
|
|
|
if response["response"]['result'] == 'error':
|
|
raise RequestError("Could not request records list to tautulli: " + response["response"]['message'])
|
|
|
|
totalRecords = response["response"]["data"]["recordsTotal"]
|
|
|
|
while True:
|
|
# Used to break from loop. See below
|
|
recentlyWatched = False
|
|
|
|
# Send http request to retrieve the next n=length items
|
|
payload = {'apikey': TAUTULLI_API_KEY, 'cmd': 'get_library_media_info', 'section_id': sectionId, 'order_column': 'last_played', 'order_dir': 'asc', 'length': length, 'start': start}
|
|
request = requests.get(TAUTULLI_SERVER_URL+"/api/v2", params=payload)
|
|
response = json.loads(request.content)
|
|
|
|
if response["response"]['result'] == 'error':
|
|
raise RequestError("Could not request records list to tautulli: " + response["response"]['message'])
|
|
|
|
# Select the items that has not been watched based on deadline policies
|
|
for media in response["response"]["data"]["data"]:
|
|
if media["last_played"] == None:
|
|
addedAt = datetime.fromtimestamp(int(media["added_at"]))
|
|
logmsg = "media: {0:50} has never been watched. Added the {1:20}".format(media["title"], addedAt.strftime("%d/%m/%Y %H:%M:%S"))
|
|
if addedAt > deadlineNeverWacthed:
|
|
logger.debug("{} -----> Ignoring".format(logmsg))
|
|
continue
|
|
else:
|
|
lastPlayed = datetime.fromtimestamp(int(media["last_played"]))
|
|
logmsg = "media: {0:50} has been last played the: {1:20}".format(media["title"], lastPlayed.strftime("%d/%m/%Y %H:%M:%S"))
|
|
if lastPlayed > deadlineLastWacthed:
|
|
logger.debug("{} -----> Ignoring".format(logmsg))
|
|
recentlyWatched = True
|
|
continue
|
|
|
|
# Item is selected for deletion
|
|
logger.debug("{} -----> To be deleted".format(logmsg))
|
|
unwatched.append(media["rating_key"])
|
|
|
|
|
|
# The media are listed in asc order of last_played (never played records comes first), so if a record has been watched there is no need to continue
|
|
if recentlyWatched == True or start > totalRecords:
|
|
break
|
|
|
|
start += length
|
|
|
|
return unwatched
|
|
|
|
# Returns the path list of all the files composing the media
|
|
def get_media_paths(rating_key):
|
|
# Get the metadata of the media
|
|
payload = {'apikey': TAUTULLI_API_KEY, 'cmd': 'get_metadata', 'rating_key': rating_key}
|
|
request = requests.get(TAUTULLI_SERVER_URL+"/api/v2", params=payload)
|
|
response = json.loads(request.content)
|
|
|
|
if response["response"]['result'] == 'error':
|
|
raise RequestError("Could not request media path for {} : {}".format(rating_key, response["response"]['message']))
|
|
|
|
match response["response"]["data"]["media_type"]:
|
|
case "movie" | "episode":
|
|
# If the media is a video file we return it as a list of single element
|
|
return [response["response"]["data"]["media_info"][0]["parts"][0]["file"]]
|
|
|
|
case "show" | "season":
|
|
# If the media is a group of media we get the path of all its components
|
|
# Getting the list of child
|
|
payload = {'apikey': TAUTULLI_API_KEY, 'cmd': 'get_children_metadata', 'rating_key': rating_key}
|
|
request = requests.get(TAUTULLI_SERVER_URL+"/api/v2", params=payload)
|
|
response = json.loads(request.content)
|
|
|
|
if response["response"]['result'] == 'error':
|
|
raise RequestError("Could not request media path for {} : {}".format(rating_key, response["response"]['message']))
|
|
|
|
# Get the path of all childs recursively
|
|
paths = []
|
|
for media in response["response"]["data"]["children_list"]:
|
|
if media["rating_key"] == "":
|
|
continue
|
|
paths.extend(get_media_paths(media["rating_key"]))
|
|
return paths
|
|
|
|
case _:
|
|
raise ApplicationError("Unkown media type for rating key {}: {} ".format(rating_key, response["response"]["data"]["media_type"]))
|
|
|
|
|
|
# Get the list of files path to be removed
|
|
def get_files_to_remove(unwatched):
|
|
logger.info("Getting path of unwatched medias")
|
|
pathToRemove = []
|
|
for media in unwatched:
|
|
paths = get_media_paths(media)
|
|
for path in paths:
|
|
if path not in FILES_TO_KEEP:
|
|
pathToRemove.append(path)
|
|
return pathToRemove
|
|
|
|
# writes the list of files selected for deletion in a file
|
|
def get_and_store_files_to_remove():
|
|
logger.info("Getting list of rating id for unwatched medias")
|
|
unwatched = []
|
|
for sectionId in FILM_SECTION_IDS:
|
|
logger.debug("Getting list for sectionID={}".format(sectionId))
|
|
unwatched.extend(get_unwatched_rating_keys(sectionId))
|
|
|
|
logger.debug("list of rating_key of unwatched medias: {}".format(unwatched))
|
|
|
|
pathToRemove = get_files_to_remove(unwatched)
|
|
logger.info("{} medias to be removed".format(len(pathToRemove)))
|
|
logger.info("List stored in {}".format(outputfile))
|
|
|
|
|
|
with open(outputfile, 'w') as f:
|
|
logger.debug("writing to '{}'".format(outputfile))
|
|
for path in pathToRemove:
|
|
f.write(f"{path}\n")
|
|
|
|
def delete_from_radarr(movies, path):
|
|
for movie in movies:
|
|
if movie["path"] in path:
|
|
# Send http request to delete movie
|
|
payload = {'apikey': RADARR_API_KEY, 'deleteFiles': 'true'}
|
|
request = requests.delete(RADARR_SERVER_URL+"/api/v3/movie/"+str(movie["id"]), params=payload)
|
|
if request.status_code == 200:
|
|
logger.debug("Successfully deleted {}".format(movie["title"]))
|
|
return True
|
|
logger.warning("recieved not 200 from raddar. : " + request.reason)
|
|
|
|
logger.debug("Failed to delete {} using raddar".format(movie["title"]))
|
|
return False
|
|
|
|
def delete_from_sonarr(name, deletedSeries, series):
|
|
if name in deletedSeries:
|
|
logger.debug("Skipping deletion of {} as it has already been deleted".format(name))
|
|
return True
|
|
|
|
for serie in series:
|
|
if serie["title"] == name:
|
|
# Send http request to delete serie
|
|
payload = {'apikey': SONARR_API_KEY, 'deleteFiles': 'true'}
|
|
request = requests.delete(SONARR_SERVER_URL+"/api/v3/series/"+str(serie["id"]), params=payload)
|
|
if request.status_code == 200:
|
|
deletedSeries.append(name)
|
|
logger.debug("Successfully deleted {}".format(name))
|
|
return True
|
|
logger.warning("recieved not 200 from sonarr. : " + request.reason)
|
|
|
|
logger.debug("Failed to delete {} using sonarr".format(name))
|
|
return False
|
|
|
|
|
|
# Delete all files and empty parent folder listed in an input file
|
|
def delete_files(inputFile):
|
|
series =[]
|
|
deletedSeries = []
|
|
movies = []
|
|
|
|
# Send http request to get the list of series
|
|
payload = {'apikey': SONARR_API_KEY}
|
|
request = requests.get(SONARR_SERVER_URL+"/api/v3/series", params=payload)
|
|
if request.status_code == 200:
|
|
series = json.loads(request.content)
|
|
else:
|
|
logger.warning("Failed to retrieve list of series from sonarr. : " + request.reason)
|
|
|
|
# Send http request to get the list of movies
|
|
payload = {'apikey': RADARR_API_KEY}
|
|
request = requests.get(RADARR_SERVER_URL+"/api/v3/movie", params=payload)
|
|
if request.status_code == 200:
|
|
movies = json.loads(request.content)
|
|
else:
|
|
logger.warning("Failed to retrieve list of series from sonarr. : " + request.reason)
|
|
|
|
logger.info("Deleting all unwanted media files listed in \"{}\"".format(inputFile))
|
|
with open(inputFile, 'r') as f:
|
|
for path in f:
|
|
path = path.strip()
|
|
logger.debug("Deleting file: " + path)
|
|
|
|
# try to delete a serie with sonarr
|
|
try:
|
|
match = re.search(r'/Plex/Series/([^/]+)/', path)
|
|
if match:
|
|
logger.debug("{} matched series path pattern for '{}'".format(path, match.group(1)))
|
|
if delete_from_sonarr(match.group(1), deletedSeries, series):
|
|
continue
|
|
|
|
except Exception as err:
|
|
logger.warning("Error while trying to remove with sonarr: {}".format(err))
|
|
|
|
# try to delete a movie with radarr
|
|
try:
|
|
match = re.search(r'/Plex/Films/([^/]+)', path)
|
|
if match:
|
|
logger.debug("{} matched movies path pattern".format(path))
|
|
if delete_from_radarr(movies, path):
|
|
continue
|
|
|
|
except Exception as err:
|
|
logger.warning("Error while trying to remove with radarr: {}".format(err))
|
|
|
|
# failed to delete with sonarr/radarr so we delete the file directly
|
|
try:
|
|
logger.debug("Failed to delete with sonarr/radarr. -> Deleting the file directly")
|
|
os.remove(path)
|
|
parent = os.path.split(path)[0]
|
|
dir = os.listdir(parent)
|
|
if len(dir) == 0:
|
|
logger.debug("Deleting empty folder: " + parent)
|
|
os.rmdir(parent)
|
|
except Exception as err:
|
|
logger.error("Error could not delete '{}' from local dir: {}".format(path, err))
|
|
|
|
|
|
###### ----- main ----- ######
|
|
|
|
### --- Create and read the cmd line --- ###
|
|
# -- Define cmd --
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
'-v', '--verbose',
|
|
help="Print lots of debugging statements",
|
|
action="store_const", dest="loglevel", const=logging.DEBUG,
|
|
default=logging.INFO,
|
|
)
|
|
parser.add_argument(
|
|
'-g', '--get',
|
|
help="Get the list of files to delete and stores the list in a file",
|
|
action="store_true"
|
|
)
|
|
parser.add_argument(
|
|
'-d', '--delete',
|
|
help="Read a file listing unwanted files, delete the unwanted files and its parent folders if empty.",
|
|
action="store_true"
|
|
)
|
|
parser.add_argument(
|
|
'-o', '--output',
|
|
help="Change the default output file.",
|
|
action="store",
|
|
default="to_delete.txt",
|
|
)
|
|
|
|
# -- Read args --
|
|
args = parser.parse_args()
|
|
logging.basicConfig(level=args.loglevel)
|
|
logger = logging.getLogger('plexwasher')
|
|
outputfile = args.output
|
|
|
|
|
|
### --- Read config.ini file --- ###
|
|
# -- Open --
|
|
logger.info("Initializing...")
|
|
config_obj = configparser.ConfigParser()
|
|
config_obj.read("config.ini")
|
|
|
|
# -- Read TAUTULLI section --
|
|
serverParam = config_obj["TAUTULLI"]
|
|
TAUTULLI_SERVER_URL = serverParam["server_url"]
|
|
TAUTULLI_API_KEY = serverParam["api_key"]
|
|
|
|
# -- Read RADARR section --
|
|
serverParam = config_obj["RADARR"]
|
|
RADARR_SERVER_URL = serverParam["server_url"]
|
|
RADARR_API_KEY = serverParam["api_key"]
|
|
|
|
# -- Read SONARR section --
|
|
serverParam = config_obj["SONARR"]
|
|
SONARR_SERVER_URL = serverParam["server_url"]
|
|
SONARR_API_KEY = serverParam["api_key"]
|
|
|
|
# -- Read MEDIA_FILTER section --
|
|
mediaParam = config_obj["MEDIA_FILTER"]
|
|
FILM_SECTION_IDS = ast.literal_eval(mediaParam["film_section_ids"])
|
|
DEADLINE_NEVER_WATCHED = int(mediaParam["deadline_never_watched"])
|
|
DEADLINE_LAST_WATCHED = int(mediaParam["deadline_last_watched"])
|
|
FILES_TO_KEEP = ast.literal_eval(mediaParam["files_to_ignore"])
|
|
|
|
logger.debug("Loaded config values: \n" \
|
|
" TAUTULLI_SERVER_URL: {}\n" \
|
|
" TAUTULLI_API_KEY: hidden\n" \
|
|
" RADARR_SERVER_URL: {}\n" \
|
|
" RADARR_API_KEY: hidden\n" \
|
|
" SONARR_SERVER_URL: {}\n" \
|
|
" SONARR_API_KEY: hidden\n" \
|
|
" FILM_SECTION_IDS: {}\n" \
|
|
" DEADLINE_NEVER_WATCHED: {}\n" \
|
|
" DEADLINE_LAST_WATCHED: {}\n" \
|
|
" FILES_TO_KEEP: {}"
|
|
.format(TAUTULLI_SERVER_URL,RADARR_SERVER_URL, SONARR_SERVER_URL, FILM_SECTION_IDS, DEADLINE_NEVER_WATCHED, DEADLINE_LAST_WATCHED, FILES_TO_KEEP))
|
|
|
|
|
|
### --- Start logic --- ###
|
|
if not args.get and not args.delete:
|
|
logger.info("Nothing to do. Please give provide arguments (ex: python3 plexwasher.py --get --delete).")
|
|
exit(0)
|
|
|
|
try:
|
|
if args.get:
|
|
get_and_store_files_to_remove()
|
|
|
|
if args.delete:
|
|
delete_files(outputfile)
|
|
except (RequestError, ApplicationError) as err:
|
|
logger.error("{}".format(err)) |