Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
5bcdf4fe43
@ -338,7 +338,7 @@ Tautulli > Settings > Notification Agents > New Script > Conditions:
|
|||||||
- [ ] Set desired conditions
|
- [ ] Set desired conditions
|
||||||
- [ ] Save
|
- [ ] Save
|
||||||
|
|
||||||
For more information on Tautulli conditions see [here](https://github.com/Tautulli/Tautulli-Wiki/wiki/Custom-Notification-Conditions)
|
For more information on Tautulli conditions see [here](https://github.com/Tautulli/Tautulli/wiki/Custom-Notification-Conditions)
|
||||||
|
|
||||||
#### Script Arguments
|
#### Script Arguments
|
||||||
Tautulli > Settings > Notification Agents > New Script > Script Arguments:
|
Tautulli > Settings > Notification Agents > New Script > Script Arguments:
|
||||||
|
@ -21,8 +21,13 @@
|
|||||||
# --rating_key {rating_key} --blur 25
|
# --rating_key {rating_key} --blur 25
|
||||||
# To add a prefix to the summary (optional string prefix):
|
# To add a prefix to the summary (optional string prefix):
|
||||||
# --rating_key {rating_key} --summary_prefix "** SPOILERS **"
|
# --rating_key {rating_key} --summary_prefix "** SPOILERS **"
|
||||||
|
# To upload the episode artwork instead of creating a local asset (optional, for when the script cannot access the media folder):
|
||||||
|
# --rating_key {rating_key} --blur 25 --upload
|
||||||
# * Watched (optional):
|
# * Watched (optional):
|
||||||
# --rating_key {rating_key} --remove
|
# To remove the local asset episode artwork:
|
||||||
|
# --rating_key {rating_key} --remove
|
||||||
|
# To remove the uploaded episode artwork
|
||||||
|
# --rating_key {rating_key} --remove --upload
|
||||||
# Note:
|
# Note:
|
||||||
# * "Use local assets" must be enabled for the library in Plex (Manage Library > Edit > Advanced > Use local assets).
|
# * "Use local assets" must be enabled for the library in Plex (Manage Library > Edit > Advanced > Use local assets).
|
||||||
|
|
||||||
@ -40,7 +45,7 @@ PLEX_URL = os.getenv('PLEX_URL', PLEX_URL)
|
|||||||
PLEX_TOKEN = os.getenv('PLEX_TOKEN', PLEX_TOKEN)
|
PLEX_TOKEN = os.getenv('PLEX_TOKEN', PLEX_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
def modify_episode_artwork(plex, rating_key, image=None, blur=None, summary_prefix=None, remove=False):
|
def modify_episode_artwork(plex, rating_key, image=None, blur=None, summary_prefix=None, remove=False, upload=False):
|
||||||
item = plex.fetchItem(rating_key)
|
item = plex.fetchItem(rating_key)
|
||||||
|
|
||||||
if item.type == 'show':
|
if item.type == 'show':
|
||||||
@ -61,21 +66,29 @@ def modify_episode_artwork(plex, rating_key, image=None, blur=None, summary_pref
|
|||||||
episode_filename = os.path.splitext(os.path.basename(episode_filepath))[0]
|
episode_filename = os.path.splitext(os.path.basename(episode_filepath))[0]
|
||||||
|
|
||||||
if remove:
|
if remove:
|
||||||
# Find image files with the same name as the episode
|
if upload:
|
||||||
for filename in os.listdir(episode_folder):
|
# Unlock and select the first poster
|
||||||
if filename.startswith(episode_filename) and filename.endswith(('.jpg', '.png')):
|
episode.unlockPoster().posters()[0].select()
|
||||||
# Delete the episode artwork image file
|
else:
|
||||||
os.remove(os.path.join(episode_folder, filename))
|
# Find image files with the same name as the episode
|
||||||
|
for filename in os.listdir(episode_folder):
|
||||||
|
if filename.startswith(episode_filename) and filename.endswith(('.jpg', '.png')):
|
||||||
|
# Delete the episode artwork image file
|
||||||
|
os.remove(os.path.join(episode_folder, filename))
|
||||||
|
|
||||||
# Unlock the summary so it will get updated on refresh
|
# Unlock the summary so it will get updated on refresh
|
||||||
episode.edit(**{'summary.locked': 0})
|
episode.editSummary(episode.summary, locked=False)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if image:
|
if image:
|
||||||
# File path to episode artwork using the same episode file name
|
if upload:
|
||||||
episode_artwork = os.path.splitext(episode_filepath)[0] + os.path.splitext(image)[1]
|
# Upload the image to the episode artwork
|
||||||
# Copy the image to the episode artwork
|
episode.uploadPoster(filepath=image)
|
||||||
shutil.copy2(image, episode_artwork)
|
else:
|
||||||
|
# File path to episode artwork using the same episode file name
|
||||||
|
episode_artwork = os.path.splitext(episode_filepath)[0] + os.path.splitext(image)[1]
|
||||||
|
# Copy the image to the episode artwork
|
||||||
|
shutil.copy2(image, episode_artwork)
|
||||||
|
|
||||||
elif blur:
|
elif blur:
|
||||||
# File path to episode artwork using the same episode file name
|
# File path to episode artwork using the same episode file name
|
||||||
@ -91,16 +104,17 @@ def modify_episode_artwork(plex, rating_key, image=None, blur=None, summary_pref
|
|||||||
r = requests.get(image_url, stream=True)
|
r = requests.get(image_url, stream=True)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
r.raw.decode_content = True
|
r.raw.decode_content = True
|
||||||
# Copy the image to the episode artwork
|
if upload:
|
||||||
with open(episode_artwork, 'wb') as f:
|
# Upload the image to the episode artwork
|
||||||
shutil.copyfileobj(r.raw, f)
|
episode.uploadPoster(filepath=r.raw)
|
||||||
|
else:
|
||||||
|
# Copy the image to the episode artwork
|
||||||
|
with open(episode_artwork, 'wb') as f:
|
||||||
|
shutil.copyfileobj(r.raw, f)
|
||||||
|
|
||||||
if summary_prefix and not episode.summary.startswith(summary_prefix):
|
if summary_prefix and not episode.summary.startswith(summary_prefix):
|
||||||
# Use a zero-width space (\u200b) for blank lines
|
# Use a zero-width space (\u200b) for blank lines
|
||||||
episode.edit(**{
|
episode.editSummary(summary_prefix + '\n\u200b\n' + episode.summary)
|
||||||
'summary.value': summary_prefix + '\n\u200b\n' + episode.summary,
|
|
||||||
'summary.locked': 1
|
|
||||||
})
|
|
||||||
|
|
||||||
# Refresh metadata for the episode
|
# Refresh metadata for the episode
|
||||||
episode.refresh()
|
episode.refresh()
|
||||||
@ -113,6 +127,7 @@ if __name__ == "__main__":
|
|||||||
parser.add_argument('--blur', type=int, default=25)
|
parser.add_argument('--blur', type=int, default=25)
|
||||||
parser.add_argument('--summary_prefix', nargs='?', const='** SPOILERS **')
|
parser.add_argument('--summary_prefix', nargs='?', const='** SPOILERS **')
|
||||||
parser.add_argument('--remove', action='store_true')
|
parser.add_argument('--remove', action='store_true')
|
||||||
|
parser.add_argument('--upload', action='store_true')
|
||||||
opts = parser.parse_args()
|
opts = parser.parse_args()
|
||||||
|
|
||||||
plex = PlexServer(PLEX_URL, PLEX_TOKEN)
|
plex = PlexServer(PLEX_URL, PLEX_TOKEN)
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
'''
|
'''
|
||||||
Description: Automatically merge multi-episode files in Plex into a single entry.
|
Description: Automatically merge multi-episode files in Plex into a single entry.
|
||||||
Author: /u/SwiftPanda16
|
Author: /u/SwiftPanda16
|
||||||
Requires: plexapi
|
Requires: plexapi, pillow (optional)
|
||||||
Notes:
|
Notes:
|
||||||
* All episodes **MUST** be organized correctly according to Plex's "Multiple Episodes in a Single File".
|
* All episodes **MUST** be organized correctly according to Plex's "Multiple Episodes in a Single File".
|
||||||
https://support.plex.tv/articles/naming-and-organizing-your-tv-show-files/#toc-4
|
https://support.plex.tv/articles/naming-and-organizing-your-tv-show-files/#toc-4
|
||||||
@ -26,25 +26,44 @@ Usage:
|
|||||||
|
|
||||||
* With renumbering episodes:
|
* With renumbering episodes:
|
||||||
python merge_multiepisodes.py --library "TV Shows" --show "SpongeBob SquarePants" --renumber
|
python merge_multiepisodes.py --library "TV Shows" --show "SpongeBob SquarePants" --renumber
|
||||||
|
|
||||||
|
* With renumbering episodes and composite thumb:
|
||||||
|
python merge_multiepisodes.py --library "TV Shows" --show "SpongeBob SquarePants" --renumber --composite-thumb
|
||||||
'''
|
'''
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import functools
|
||||||
|
import io
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
|
import requests
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from plexapi.server import PlexServer
|
from plexapi.server import PlexServer
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
hasPIL = True
|
||||||
|
except ImportError:
|
||||||
|
hasPIL = False
|
||||||
|
|
||||||
|
|
||||||
# ## EDIT SETTINGS ##
|
# ## EDIT SETTINGS ##
|
||||||
|
|
||||||
PLEX_URL = ''
|
PLEX_URL = ''
|
||||||
PLEX_TOKEN = ''
|
PLEX_TOKEN = ''
|
||||||
|
|
||||||
|
# Composite Thumb Settings
|
||||||
|
WIDTH, HEIGHT = 640, 360 # 16:9 aspect ratio
|
||||||
|
LINE_ANGLE = 25 # degrees
|
||||||
|
LINE_THICKNESS = 10
|
||||||
|
|
||||||
|
|
||||||
# Environmental Variables
|
# Environmental Variables
|
||||||
PLEX_URL = os.getenv('PLEX_URL', PLEX_URL)
|
PLEX_URL = os.getenv('PLEX_URL', PLEX_URL)
|
||||||
PLEX_TOKEN = os.getenv('PLEX_TOKEN', PLEX_TOKEN)
|
PLEX_TOKEN = os.getenv('PLEX_TOKEN', PLEX_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
def group_episodes(plex, library, show, renumber):
|
def group_episodes(plex, library, show, renumber, composite_thumb):
|
||||||
show = plex.library.section(library).get(show)
|
show = plex.library.section(library).get(show)
|
||||||
|
|
||||||
for season in show.seasons():
|
for season in show.seasons():
|
||||||
@ -71,27 +90,32 @@ def group_episodes(plex, library, show, renumber):
|
|||||||
directors.extend([director.tag for director in episode.directors])
|
directors.extend([director.tag for director in episode.directors])
|
||||||
|
|
||||||
if episodes:
|
if episodes:
|
||||||
|
if composite_thumb:
|
||||||
|
firstImgFile = download_image(
|
||||||
|
plex.transcodeImage(first.thumbUrl, width=WIDTH, height=HEIGHT)
|
||||||
|
)
|
||||||
|
lastImgFile = download_image(
|
||||||
|
plex.transcodeImage(episodes[-1].thumbUrl, width=WIDTH, height=HEIGHT)
|
||||||
|
)
|
||||||
|
compImgFile = create_composite_thumb(firstImgFile, lastImgFile)
|
||||||
|
first.uploadPoster(filepath=compImgFile)
|
||||||
|
|
||||||
merge(first, episodes)
|
merge(first, episodes)
|
||||||
|
|
||||||
first.addWriter(writers, locked=True)
|
first.batchEdits() \
|
||||||
first.addDirector(directors, locked=True)
|
.editTitle(title[:-3]) \
|
||||||
|
.editSortTitle(titleSort[:-3]) \
|
||||||
edits = {
|
.editSummary(summary[:-2]) \
|
||||||
'title.value': title[:-3],
|
.editContentRating(first.contentRating) \
|
||||||
'title.locked': 1,
|
.editOriginallyAvailable(first.originallyAvailableAt) \
|
||||||
'titleSort.value': titleSort[:-3],
|
.addWriter(writers) \
|
||||||
'titleSort.locked': 1,
|
.addDirector(directors) \
|
||||||
'summary.value': summary[:-2],
|
|
||||||
'summary.locked': 1,
|
|
||||||
'originallyAvailableAt.locked': 1,
|
|
||||||
'contentRating.locked': 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if renumber:
|
if renumber:
|
||||||
edits['index.value'] = index
|
first._edits['index.value'] = index
|
||||||
edits['index.locked'] = 1
|
first._edits['index.locked'] = 1
|
||||||
|
|
||||||
first.edit(**edits)
|
first.saveEdits()
|
||||||
|
|
||||||
|
|
||||||
def merge(first, episodes):
|
def merge(first, episodes):
|
||||||
@ -99,12 +123,94 @@ def merge(first, episodes):
|
|||||||
first._server.query(key, method=first._server._session.put)
|
first._server.query(key, method=first._server._session.put)
|
||||||
|
|
||||||
|
|
||||||
|
def download_image(url):
|
||||||
|
r = requests.get(url, stream=True)
|
||||||
|
r.raw.decode_content = True
|
||||||
|
return r.raw
|
||||||
|
|
||||||
|
|
||||||
|
def create_composite_thumb(firstImgFile, lastImgFile):
|
||||||
|
mask, line = create_masks()
|
||||||
|
|
||||||
|
# Open and crop first image
|
||||||
|
firstImg = Image.open(firstImgFile)
|
||||||
|
width, height = firstImg.size
|
||||||
|
firstImg = firstImg.crop(
|
||||||
|
(
|
||||||
|
(width - WIDTH) // 2,
|
||||||
|
(height - HEIGHT) // 2,
|
||||||
|
(width + WIDTH) // 2,
|
||||||
|
(height + HEIGHT) // 2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Open and crop last image
|
||||||
|
lastImg = Image.open(lastImgFile)
|
||||||
|
width, height = lastImg.size
|
||||||
|
lastImg = lastImg.crop(
|
||||||
|
(
|
||||||
|
(width - WIDTH) // 2,
|
||||||
|
(height - HEIGHT) // 2,
|
||||||
|
(width + WIDTH) // 2,
|
||||||
|
(height + HEIGHT) // 2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create composite image
|
||||||
|
comp = Image.composite(line, Image.composite(firstImg, lastImg, mask), line)
|
||||||
|
|
||||||
|
# Return composite image as file-like object
|
||||||
|
compImgFile = io.BytesIO()
|
||||||
|
comp.save(compImgFile, format='jpeg')
|
||||||
|
compImgFile.seek(0)
|
||||||
|
return compImgFile
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=None)
|
||||||
|
def create_masks():
|
||||||
|
scale = 3 # For line anti-aliasing
|
||||||
|
offset = HEIGHT // 2 * math.tan(LINE_ANGLE * math.pi / 180)
|
||||||
|
|
||||||
|
# Create diagonal mask
|
||||||
|
mask = Image.new('L', (WIDTH, HEIGHT), 0)
|
||||||
|
draw = ImageDraw.Draw(mask)
|
||||||
|
draw.polygon(
|
||||||
|
(
|
||||||
|
(0, 0),
|
||||||
|
(WIDTH // 2 + offset, 0),
|
||||||
|
(WIDTH // 2 - offset, HEIGHT),
|
||||||
|
(0, HEIGHT)
|
||||||
|
),
|
||||||
|
fill=255
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create diagonal line (use larger image then scale down with anti-aliasing)
|
||||||
|
line = Image.new('L', (scale * WIDTH, scale * HEIGHT), 0)
|
||||||
|
draw = ImageDraw.Draw(line)
|
||||||
|
draw.line(
|
||||||
|
(
|
||||||
|
(scale * (WIDTH // 2 + offset), -scale),
|
||||||
|
(scale * (WIDTH // 2 - offset), scale * (HEIGHT + 1))
|
||||||
|
),
|
||||||
|
fill=255,
|
||||||
|
width=scale * LINE_THICKNESS
|
||||||
|
)
|
||||||
|
line = line.resize((WIDTH, HEIGHT), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
return mask, line
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument('--library', required=True)
|
parser.add_argument('--library', required=True)
|
||||||
parser.add_argument('--show', required=True)
|
parser.add_argument('--show', required=True)
|
||||||
parser.add_argument('--renumber', action='store_true')
|
parser.add_argument('--renumber', action='store_true')
|
||||||
|
parser.add_argument('--composite_thumb', action='store_true')
|
||||||
opts = parser.parse_args()
|
opts = parser.parse_args()
|
||||||
|
|
||||||
|
if opts.composite_thumb and not hasPIL:
|
||||||
|
print('PIL is not installed. Please install `pillow` to create composite thumbnails.')
|
||||||
|
exit(1)
|
||||||
|
|
||||||
plex = PlexServer(PLEX_URL, PLEX_TOKEN)
|
plex = PlexServer(PLEX_URL, PLEX_TOKEN)
|
||||||
group_episodes(plex, **vars(opts))
|
group_episodes(plex, **vars(opts))
|
||||||
|
Loading…
Reference in New Issue
Block a user