plex_thumbnails/original.py
deathbybandaid 8c0f7b23e3 first
2022-12-20 17:43:27 -05:00

272 lines
9.8 KiB
Python

#!/usr/bin/env python3
# EDIT These Vars #
PLEX_URL = 'https://xxxxxx.plex.direct:32400/' # If running locally, can also enter IP directly "https://127.0.0.1:32400/"
PLEX_TOKEN = 'xxxxxx'
PLEX_BIF_FRAME_INTERVAL = 5
THUMBNAIL_QUALITY = 4 # Allowed range is 2 - 6 with 2 being highest quality and largest file size and 6 being lowest quality and smallest file size. #
PLEX_LOCAL_MEDIA_PATH = '/path_to/plex/Library/Application Support/Plex Media Server/Media'
TMP_FOLDER = '/dev/shm/plex_generate_previews'
PLEX_LOCAL_VIDEOS_PATH_MAPPING = '/path/this/script/sees/to/video/library'
PLEX_VIDEOS_PATH_MAPPING = '/path/plex/sees/to/video/library'
GPU_THREADS = 4
CPU_THREADS = 4
# DO NOT EDIT BELOW HERE #
import os
import sys
from concurrent.futures import ProcessPoolExecutor
import re
import subprocess
import shutil
import multiprocessing
import glob
import os
import struct
if not shutil.which("mediainfo"):
print('MediaInfo not found. MediaInfo must be installed and available in PATH.')
sys.exit(1)
try:
from pymediainfo import MediaInfo
except ImportError:
print('Dependencies Missing! Please run "pip3 install pymediainfo".')
sys.exit(1)
try:
import gpustat
except ImportError:
print('Dependencies Missing! Please run "pip3 install gpustat".')
sys.exit(1)
import time
try:
import requests
except ImportError:
print('Dependencies Missing! Please run "pip3 install requests".')
sys.exit(1)
import array
try:
from plexapi.server import PlexServer
except ImportError:
print('Dependencies Missing! Please run "pip3 install plexapi".')
sys.exit(1)
try:
from loguru import logger
except ImportError:
print('Dependencies Missing! Please run "pip3 install loguru".')
sys.exit(1)
try:
from rich.console import Console
except ImportError:
print('Dependencies Missing! Please run "pip3 install rich".')
sys.exit(1)
try:
from rich.progress import Progress, SpinnerColumn, MofNCompleteColumn
except ImportError:
print('Dependencies Missing! Please run "pip3 install rich".')
sys.exit(1)
FFMPEG_PATH = shutil.which("ffmpeg")
if not FFMPEG_PATH:
print('FFmpeg not found. FFmpeg must be installed and available in PATH.')
sys.exit(1)
console = Console(color_system=None, stderr=True)
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def generate_images(video_file_param, output_folder, lock):
video_file = video_file_param.replace(PLEX_VIDEOS_PATH_MAPPING, PLEX_LOCAL_VIDEOS_PATH_MAPPING)
media_info = MediaInfo.parse(video_file)
vf_parameters = "fps=fps={}:round=up,scale=w=320:h=240:force_original_aspect_ratio=decrease".format(round(1 / PLEX_BIF_FRAME_INTERVAL, 6))
# Check if we have a HDR Format. Note: Sometimes it can be returned as "None" (string) hence the check for None type or "None" (String)
if media_info.video_tracks[0].hdr_format != "None" and media_info.video_tracks[0].hdr_format is not None:
vf_parameters = "fps=fps={}:round=up,zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p,scale=w=320:h=240:force_original_aspect_ratio=decrease".format(round(1 / PLEX_BIF_FRAME_INTERVAL, 6))
args = [
FFMPEG_PATH, "-loglevel", "info", "-skip_frame:v", "nokey", "-threads:0", "1", "-i",
video_file, "-an", "-sn", "-dn", "-q:v", str(THUMBNAIL_QUALITY),
"-vf",
vf_parameters, '{}/img-%06d.jpg'.format(output_folder)
]
start = time.time()
hw = False
with lock:
gpu = gpustat.core.new_query()[0]
gpu_ffmpeg = [c for c in gpu.processes if c["command"].lower() == 'ffmpeg']
if len(gpu_ffmpeg) < GPU_THREADS or CPU_THREADS == 0:
hw = True
args.insert(5, "-hwaccel")
args.insert(6, "cuda")
proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
# Allow time for it to start
time.sleep(1)
out, err = proc.communicate()
if proc.returncode != 0:
err_lines = err.decode('utf-8').split('\n')[-5:]
logger.error(err_lines)
raise Exception('Problem trying to ffmpeg images for {}'.format(video_file))
# Speed
end = time.time()
seconds = round(end - start, 1)
speed = re.findall('speed= ?([0-9]+\.?[0-9]*|\.[0-9]+)x', err.decode('utf-8'))
if speed:
speed = speed[-1]
logger.info('Generated Video Preview for {} HW={} TIME={}seconds SPEED={}x '.format(video_file, hw, seconds, speed))
# Optimize and Rename Images
for image in glob.glob('{}/img*.jpg'.format(output_folder)):
frame_no = int(os.path.basename(image).strip('-img').strip('.jpg')) - 1
frame_second = frame_no * PLEX_BIF_FRAME_INTERVAL
os.rename(image, os.path.join(output_folder, '{:010d}.jpg'.format(frame_second)))
def generate_bif(bif_filename, images_path):
"""
Build a .bif file
@param bif_filename name of .bif file to create
@param images_path Directory of image files 00000001.jpg
"""
magic = [0x89, 0x42, 0x49, 0x46, 0x0d, 0x0a, 0x1a, 0x0a]
version = 0
images = [img for img in os.listdir(images_path) if os.path.splitext(img)[1] == '.jpg']
images.sort()
f = open(bif_filename, "wb")
array.array('B', magic).tofile(f)
f.write(struct.pack("<I", version))
f.write(struct.pack("<I", len(images)))
f.write(struct.pack("<I", 1000 * PLEX_BIF_FRAME_INTERVAL))
array.array('B', [0x00 for x in range(20, 64)]).tofile(f)
bif_table_size = 8 + (8 * len(images))
image_index = 64 + bif_table_size
timestamp = 0
# Get the length of each image
for image in images:
statinfo = os.stat(os.path.join(images_path, image))
f.write(struct.pack("<I", timestamp))
f.write(struct.pack("<I", image_index))
timestamp += 1
image_index += statinfo.st_size
f.write(struct.pack("<I", 0xffffffff))
f.write(struct.pack("<I", image_index))
# Now copy the images
for image in images:
data = open(os.path.join(images_path, image), "rb").read()
f.write(data)
f.close()
def process_item(item_key, lock):
sess = requests.Session()
sess.verify = False
plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=sess)
data = plex.query('{}/tree'.format(item_key))
for media_part in data.findall('.//MediaPart'):
if 'hash' in media_part.attrib:
# Filter Processing by HDD Path
if len(sys.argv) > 1:
if sys.argv[1] not in media_part.attrib['file']:
return
bundle_hash = media_part.attrib['hash']
bundle_file = '{}/{}{}'.format(bundle_hash[0], bundle_hash[1::1], '.bundle')
bundle_path = os.path.join(PLEX_LOCAL_MEDIA_PATH, bundle_file)
indexes_path = os.path.join(bundle_path, 'Contents', 'Indexes')
index_bif = os.path.join(indexes_path, 'index-sd.bif')
tmp_path = os.path.join(TMP_FOLDER, bundle_hash)
if (not os.path.isfile(index_bif)) and (not os.path.isdir(tmp_path)):
if not os.path.isdir(indexes_path):
os.mkdir(indexes_path)
try:
os.mkdir(tmp_path)
generate_images(media_part.attrib['file'], tmp_path, lock)
generate_bif(index_bif, tmp_path)
except Exception as e:
# Remove bif, as it prob failed to generate
if os.path.exists(index_bif):
os.remove(index_bif)
logger.error(e)
finally:
if os.path.exists(tmp_path):
shutil.rmtree(tmp_path)
def run():
process_pool = ProcessPoolExecutor(max_workers=CPU_THREADS + GPU_THREADS)
# Ignore SSL Errors
sess = requests.Session()
sess.verify = False
plex = PlexServer(PLEX_URL, PLEX_TOKEN, session=sess)
# Get all Movies
logger.info('Getting Movies from Plex')
movies = [m.key for m in plex.library.search(libtype='movie')]
logger.info('Got {} Movies from Plex', len(movies))
m = multiprocessing.Manager()
lock = m.Lock()
futures = [process_pool.submit(process_item, key, lock) for key in movies]
with Progress(SpinnerColumn(), *Progress.get_default_columns(), MofNCompleteColumn(), console=console) as progress:
for future in progress.track(futures):
future.result()
# Get all Episodes
logger.info('Getting Episodes from Plex')
episodes = [m.key for m in plex.library.search(libtype='episode')]
logger.info('Got {} Episodes from Plex', len(episodes))
futures = [process_pool.submit(process_item, key, lock) for key in episodes]
with Progress(SpinnerColumn(), *Progress.get_default_columns(), MofNCompleteColumn(), console=console) as progress:
for future in progress.track(futures):
future.result()
process_pool.shutdown()
if __name__ == '__main__':
logger.remove() # Remove default 'stderr' handler
# We need to specify end=''" as log message already ends with \n (thus the lambda function)
# Also forcing 'colorize=True' otherwise Loguru won't recognize that the sink support colors
logger.add(lambda m: console.print('\n%s' % m, end=""), colorize=True)
if not os.path.exists(PLEX_LOCAL_MEDIA_PATH):
logger.error('%s does not exist, please edit PLEX_LOCAL_MEDIA_PATH variable' % PLEX_LOCAL_MEDIA_PATH)
exit(1)
if 'xxxxxx' in PLEX_URL:
logger.error('Please update the PLEX_URL variable within this script')
exit(1)
if 'xxxxxx' in PLEX_TOKEN:
logger.error('Please update the PLEX_TOKEN variable within this script')
exit(1)
try:
# Clean TMP Folder
if os.path.isdir(TMP_FOLDER):
shutil.rmtree(TMP_FOLDER)
os.mkdir(TMP_FOLDER)
run()
finally:
if os.path.isdir(TMP_FOLDER):
shutil.rmtree(TMP_FOLDER)