From 466525b8ac9738bada48930225dc73387dccee3f Mon Sep 17 00:00:00 2001 From: Akiya Date: Sat, 7 Jun 2025 01:58:55 +0200 Subject: [PATCH] add clean of movies --- .gitignore | 2 + README.md | 41 ++++++++- config.exemple.ini | 16 ++++ plexwasher.py | 214 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 config.exemple.ini create mode 100755 plexwasher.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4dc6255 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +config.ini +to_delete.txt \ No newline at end of file diff --git a/README.md b/README.md index 3496dfc..4a96740 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,42 @@ # PlexWasher -A simple command line to remove media from plex \ No newline at end of file +A simple command line to remove media from plex + +## Getting Started + +### Dependencies + +* python3.10 or newer + +### Installing + +* Download source code 'plexwasher.py' and 'config.exemple.ini' files in the same folder +``` +git clone https://git.antoinesanchez.fr/Akiya/PlexWasher.git # or download the ZIP from https://git.antoinesanchez.fr/Akiya/PlexWasher/archive/main.zip and extract it +``` + +* Rename 'config.exemple.ini' to 'config.ini' and modify it to set the correct informations + +### Executing program + +* CMD description: +``` +usage: plexwasher.py [-h] [-v] [-g] [-d] [-o OUTPUT] + +options: + -h, --help show this help message and exit + -v, --verbose Print lots of debugging statements + -g, --get Get the list of files to delete and stores the list in a file + -d, --delete Read a file listing unwanted files, delete the unwanted files and its parent folders if empty. + -o OUTPUT, --output OUTPUT + Change the default output file. +``` + +* Exemple: +``` +./plexwasher.py --get +``` + +## License + +This project is licensed under the Apache-2.0 License - see the LICENSE file for details diff --git a/config.exemple.ini b/config.exemple.ini new file mode 100644 index 0000000..ea93b3a --- /dev/null +++ b/config.exemple.ini @@ -0,0 +1,16 @@ +[SERVER] +SERVER_URL = https://tautulli.exemple.com/ +API_KEY = e18e33df8f7sdfae92d3a2344f2f60 + +[MEDIA_FILTER] +; Section_id de la librairie film à nettoyer +film_section_ids = 1 +; nb de mois avant suppression pour un film jamais regardé +deadline_never_watched = 60 +; nb de mois avant suppression depuis le dernier visionage +deadline_last_watched = 12 +files_to_ignore = [ + "/share/data/Plex/Films/Inglourious Basterds - 2009/Inglourious Basterds - 2009.mkv", + "/share/data/Plex/Films/N'oublie jamais - 2004/The Notebook - 2004.mkv", + "/share/data/Plex/Films/Nuit D'Ivresse -1986.mkv" + ] \ No newline at end of file diff --git a/plexwasher.py b/plexwasher.py new file mode 100755 index 0000000..e50961c --- /dev/null +++ b/plexwasher.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3.10 + +import configparser +import logging +import traceback +import argparse +import json +import os +import sys +import requests +from datetime import datetime +from dateutil.relativedelta import relativedelta + +class RequestError(Exception): + pass + +class ApplicationError(Exception): + pass + +SERVER_URL = "" +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': 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(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': API_KEY, 'cmd': 'get_library_media_info', 'section_id': sectionId, 'order_column': 'last_played', 'order_dir': 'asc', 'length': length, 'start': start} + request = requests.get(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 + +# Get the file path of the media in the filesystem +def get_media_path(rating_key): + payload = {'apikey': API_KEY, 'cmd': 'get_metadata', 'rating_key': rating_key} + request = requests.get(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": + return response["response"]["data"]["media_info"][0]["parts"][0]["file"] + case "show": + raise ApplicationError("media path for shows are not implemented yet") # TODO: handle case for shows + 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: + path = get_media_path(media) + 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 = get_unwatched_rating_keys(FILM_SECTION_IDS) + 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") + +# Delete all files and empty parent folder listed in an input file +def delete_files(inputFile): + 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) + 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) + + +###### ----- 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 SERVER section -- +serverParam = config_obj["SERVER"] +SERVER_URL = serverParam["server_url"] +API_KEY = serverParam["api_key"] + +# -- Read MEDIA_FILTER section -- +mediaParam = config_obj["MEDIA_FILTER"] +FILM_SECTION_IDS = int(mediaParam["film_section_ids"]) +DEADLINE_NEVER_WATCHED = int(mediaParam["deadline_never_watched"]) +DEADLINE_LAST_WATCHED = int(mediaParam["deadline_last_watched"]) +FILES_TO_KEEP = mediaParam["files_to_ignore"] + +logger.debug("Loaded config values: \n SERVER_URL: {}\n API_KEY: hidden FILM_SECTION_IDS: {}\n DEADLINE_NEVER_WATCHED: {}\n DEADLINE_LAST_WATCHED: {}\n FILES_TO_KEEP: {}".format(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(err) \ No newline at end of file