add clean of movies

This commit is contained in:
Akiya 2025-06-07 01:58:55 +02:00
parent ea054648aa
commit 466525b8ac
4 changed files with 272 additions and 1 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
config.ini
to_delete.txt

View File

@ -1,3 +1,42 @@
# PlexWasher
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

16
config.exemple.ini Normal file
View File

@ -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"
]

214
plexwasher.py Executable file
View File

@ -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)