Skip to content

Commit

Permalink
Added preserve_collections fixup#
Browse files Browse the repository at this point in the history
Signed-off-by: Andreas Maier <andreas.r.maier@gmx.de>
  • Loading branch information
andy-maier committed Jan 7, 2022
1 parent 59506d1 commit c911550
Show file tree
Hide file tree
Showing 4 changed files with 310 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
/try/
.ipynb_checkpoints/
/*.done
collections.yaml
*.pyc
*.pyo
*.log
Expand Down
13 changes: 13 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ Currently supported fixups are:
remove useless genres, and set a default genre if the list of genres is empty.
These changes can be configured in the config file.

* preserve_collections:

This fixup walks through the movie and show items of the configured library
sections of the Plex Media Server, and preserves the collections of each item.
This is done by syncing any present collections between PMS and a collections
file. This solves the issue that PMS resets the collections every now and
then, when updating the metadata of the item. Since the presence of a
collection either in PMS or in the collections file causes it to be synced,
the intentional removal of a collection now requires removing it from PMS and
removing it from the collections file. The collections file is in YAML format
and is self-explanatory. The path name of the collections file can be
configured in the config file.

Look at the example plexmediafixup config file `example_config_file.yml`_ if
you want to see what can be configured. It describes each parameter.

Expand Down
30 changes: 30 additions & 0 deletions example_config_file.yml
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,33 @@ fixups:
# "video_genre_cleanup" config parameter, using the two-letter language
# codes defined in ISO 639-1.
language: de

# preserve_collections is a fixup that walks through the movie and show items
# of the configured library sections, and preserves the "Collections" field of
# each item, by syncing that data in both directions between PMS and a
# collections file. The syncing is based on currently present collections: If
# a collection is present, it will be synced to the other side. In order to
# remove a collection from a movie/show item, the user needs to remove it from
# both PMS and the collections file.
- name: preserve_collections
enabled: true
kwargs:

# String or list of strings that specify the library section types that
# should be processed. Valid values are: 'movie', 'show'. For 'show',
# only the show items will be processed, but not the episodes (they do
# not define a separate genre). A value of null means to process all
# valid section types.
# Optional, default is null.
section_types: null

# String that specifies a regex pattern defining library section names
# that should be processed within the configured section types. A value
# of null means to process all library sections of the configured types.
# Optional, default is null.
section_pattern: null

# Path name of the collections file. Relative path names are relative to
# the directory of this config file. The collections file is in YAML
# format.
collections_file: collections.yaml
266 changes: 266 additions & 0 deletions plexmediafixup/fixups/preserve_collections.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
"""
Fixup that walks through the movie and show items of the configured library
sections, and preserves the tags of each item. This is done by syncing the tags
between a collections file and PMS.
The file name of the collections file can be configured in the config file.
"""

from __future__ import print_function, absolute_import
import os
import re
import json
import six
import yaml # PyYAML package
import yamlloader
from unidecode import unidecode
import plexapi
import plexapi.exceptions
import plexapi.utils
import requests.exceptions
from plexmediafixup.fixup import Fixup
from plexmediafixup.utils.unicode import ensure_bytes, ensure_unicode
from plexmediafixup.utils.watcher import Watcher


FIXUP_NAME = os.path.splitext(os.path.basename(__file__))[0]


class PreserveCollections(Fixup):

def __init__(self):
super(PreserveCollections, self).__init__(FIXUP_NAME)

def run(self, plex, dryrun, verbose, config, fixup_kwargs):
"""
Parameters:
plex (plexapi.PlexServer): PMS to work against.
dryrun (bool): Dryrun flag from command line.
verbose (bool): Verbose flag from command line.
config (ConfigFile): The config file.
fixup_kwargs (dict): The kwargs config parameter for the fixup,
with the following items:
section_types (string or iterable(string)):
String or list of strings that specify the library section types
that should be processed. Valid values are: 'movie', 'show'. For
'show', only the show items will be processed, but not the
episodes (they do not define a separate collection). A value of
None (null in config file) means to process all valid section
types. Optional, default is None.
section_pattern (string):
Regex pattern defining library section names that should be
processed within the configured section types. A value of None
(null in config file) means to process all library sections of
the configured types. Optional, default is None.
collections_file (string):
Path name of collections file. Relative file paths are interpreted
relative to the directory of the config file. The collections
file is created if needed, and has a YAML format.
"""

section_types = fixup_kwargs.get('section_types', None)
section_pattern = fixup_kwargs.get('section_pattern', None)
coll_file = fixup_kwargs.get('collections_file', None)

if not coll_file:
print("Error: No 'collections_file' config parameter specified for "
"fixup {fixup}".
format(fixup=FIXUP_NAME))
return 1

if not os.path.isabs(coll_file):
coll_file = os.path.join(
os.path.dirname(config.filepath),
coll_file)
print("Using collections file: {f}".format(f=coll_file))

try:
with open(coll_file, 'r', encoding='utf-8') as fp:
if verbose:
print("Reading collections file: {f}".format(f=coll_file))
coll_dict = yaml.safe_load(fp)
if coll_dict is None:
coll_dict = {}
except FileNotFoundError as exc:
coll_dict = {}
except (OSError, IOError) as exc:
print("Error: Cannot open collections file {f} for reading: {msg}".
format(f=coll_file, msg=exc))
return 1
except yaml.YAMLError as exc:
print("Error: Cannot parse collections file {f} as YAML: {msg}".
format(f=coll_file, msg=exc))
return 1

if section_types is None:
section_types = ['movie', 'show']
elif isinstance(section_types, six.string_types):
section_types = [section_types]
for st in section_types:
if st not in ['movie', 'show']:
print("Error: Invalid section type specified for fixup "
"{fixup}: {type}".
format(fixup=FIXUP_NAME, type=st))
return 1

try:
with Watcher() as w:
sections = plex.library.sections()
except (plexapi.exceptions.PlexApiException,
requests.exceptions.RequestException) as exc:
print("Error: Cannot list sections: {msg} ({w.debug_str})".
format(msg=exc, w=w))
return 1

for section in sections:

if section.type not in section_types:
continue
if section_pattern is not None and \
re.search(section_pattern, section.title) is None:
if verbose:
print("Skipping {s.type} section {s.title!r} "
"that does not match the specified pattern".
format(s=section))
continue

print("Processing {s.type} section {s.title!r}".
format(s=section))

try:
with Watcher() as w:
items = section.all()
except (plexapi.exceptions.PlexApiException,
requests.exceptions.RequestException) as exc:
print("Error: Cannot list all items in {s.type} section "
"{s.title!r}: {msg} ({w.debug_str})".
format(s=section, msg=exc, w=w))
return 1

for item in items:
if item.type == 'movie':
rc = process_item(dryrun, verbose, item, coll_dict)
if rc:
return rc
elif item.type == 'show':
rc = process_item(dryrun, verbose, item, coll_dict)
if rc:
return rc
else:
print("Error: Invalid section type {type!r} encountered in "
"section {s.title!r}".
format(type=item.type, s=section))
return 1

if not dryrun:
data = yaml.dump(
coll_dict, encoding=None, allow_unicode=True,
default_flow_style=False, indent=4,
Dumper=yamlloader.ordereddict.CSafeDumper)
try:
with open(coll_file, 'w', encoding='utf-8') as fp:
if verbose:
print("Writing collections file: {f}".
format(f=coll_file))
fp.write(data)
except (OSError, IOError) as exc:
print("Error: Cannot open collections file {f} for writing: "
"{msg}".format(f=coll_file, msg=exc))
return 1

return 0


def process_item(dryrun, verbose, item, coll_dict):
"""
Process one movie or show item.
Parameters:
item (plexapi.video.Movie or plexapi.video.Show): movie or show item to
be processed.
coll_dict (dict): Collections dictionary from the collections file, with:
* key (string): ID of the item
* value (dict): Attributes of the item, as follows:
- 'section': Title of the section of the item
- 'title': Title of the item
- 'year': Year of the item
- 'collections': List of collection names of the item
"""

dryrun_str = "Dryrun: " if dryrun else ""

# If the item is not fully loaded, it may show only a subset of collections.
if not item.isFullObject():
item.reload()

item_collections = [] # List of collection names in item
if item.collections:
for c in item.collections: # list of plexapi.media.Collection
t = c.tag
if isinstance(t, six.binary_type):
t = t.decode('utf-8')
item_collections.append(t)

item_id = item.key.split('/')[-1]
item_section = item.section().title
item_title = item.title
item_year = item.year

if item_id not in coll_dict:
if verbose:
print("{d}Creating {s!r} item in collections file: {t!r} ({y})".
format(d=dryrun_str, s=item_section, t=item_title,
y=item_year))
coll_dict[item_id] = {
'section': item_section,
'title': item_title,
'year': item_year,
'collections': [],
}
file_item_dict = coll_dict[item_id]
else:
file_item_dict = coll_dict[item_id]
if item_section != file_item_dict.get('section', None) or \
item_title != file_item_dict.get('title', None) or \
item_year != file_item_dict.get('year', None):
if verbose:
print("{d}Updating section/title/year in collections file for "
"{s!r} item: {t!r} ({y})".
format(d=dryrun_str, s=item_section, t=item_title,
y=item_year))
file_item_dict['section'] = item_section
file_item_dict['title'] = item_title
file_item_dict['year'] = item_year

# Sync collections from PMS to collections file
for coll in item_collections:
if coll not in file_item_dict['collections']:
if verbose:
print("{d}Saving collection {c!r} to collections file for "
"{s!r} item: {t!r} ({y})".
format(d=dryrun_str, c=coll, s=item_section,
t=item_title, y=item_year))
file_item_dict['collections'].append(coll)

# Sync collections from collections file to PMS
for coll in file_item_dict['collections']:
if coll not in item_collections:
if verbose:
print("{d}Restoring collection '{c}' from collections file for "
"{s!r} item: {t!r} ({y})".
format(d=dryrun_str, c=coll, s=item_section,
t=item_title, y=item_year))
item_collections.append(coll)
if not dryrun:
item.addCollection(coll)

return 0

0 comments on commit c911550

Please sign in to comment.