diff --git a/lib/charset_normalizer/api.py b/lib/charset_normalizer/api.py index 6c7e8983..9dbf4201 100644 --- a/lib/charset_normalizer/api.py +++ b/lib/charset_normalizer/api.py @@ -175,7 +175,6 @@ def from_bytes( prioritized_encodings.append("utf_8") for encoding_iana in prioritized_encodings + IANA_SUPPORTED: - if cp_isolation and encoding_iana not in cp_isolation: continue @@ -318,7 +317,9 @@ def from_bytes( bom_or_sig_available and strip_sig_or_bom is False ): break - except UnicodeDecodeError as e: # Lazy str loading may have missed something there + except ( + UnicodeDecodeError + ) as e: # Lazy str loading may have missed something there logger.log( TRACE, "LazyStr Loading: After MD chunk decode, code page %s does not fit given bytes sequence at ALL. %s", diff --git a/lib/charset_normalizer/cd.py b/lib/charset_normalizer/cd.py index ae2813fb..6e56fe84 100644 --- a/lib/charset_normalizer/cd.py +++ b/lib/charset_normalizer/cd.py @@ -140,7 +140,6 @@ def alphabet_languages( source_have_accents = any(is_accentuated(character) for character in characters) for language, language_characters in FREQUENCIES.items(): - target_have_accents, target_pure_latin = get_target_features(language) if ignore_non_latin and target_pure_latin is False: diff --git a/lib/charset_normalizer/cli/normalizer.py b/lib/charset_normalizer/cli/normalizer.py index ad26b4d0..f4bcbaac 100644 --- a/lib/charset_normalizer/cli/normalizer.py +++ b/lib/charset_normalizer/cli/normalizer.py @@ -147,7 +147,6 @@ def cli_detect(argv: Optional[List[str]] = None) -> int: x_ = [] for my_file in args.files: - matches = from_fp(my_file, threshold=args.threshold, explain=args.verbose) best_guess = matches.best() @@ -222,7 +221,6 @@ def cli_detect(argv: Optional[List[str]] = None) -> int: ) if args.normalize is True: - if best_guess.encoding.startswith("utf") is True: print( '"{}" file does not need to be normalized, as it already came from unicode.'.format( diff --git a/lib/charset_normalizer/legacy.py b/lib/charset_normalizer/legacy.py index b266d176..43aad21a 100644 --- a/lib/charset_normalizer/legacy.py +++ b/lib/charset_normalizer/legacy.py @@ -1,10 +1,13 @@ -from typing import Dict, Optional, Union +from typing import Any, Dict, Optional, Union +from warnings import warn from .api import from_bytes from .constant import CHARDET_CORRESPONDENCE -def detect(byte_str: bytes) -> Dict[str, Optional[Union[str, float]]]: +def detect( + byte_str: bytes, should_rename_legacy: bool = False, **kwargs: Any +) -> Dict[str, Optional[Union[str, float]]]: """ chardet legacy method Detect the encoding of the given byte string. It should be mostly backward-compatible. @@ -13,7 +16,14 @@ def detect(byte_str: bytes) -> Dict[str, Optional[Union[str, float]]]: further information. Not planned for removal. :param byte_str: The byte sequence to examine. + :param should_rename_legacy: Should we rename legacy encodings + to their more modern equivalents? """ + if len(kwargs): + warn( + f"charset-normalizer disregard arguments '{','.join(list(kwargs.keys()))}' in legacy function detect()" + ) + if not isinstance(byte_str, (bytearray, bytes)): raise TypeError( # pragma: nocover "Expected object of type bytes or bytearray, got: " @@ -34,10 +44,11 @@ def detect(byte_str: bytes) -> Dict[str, Optional[Union[str, float]]]: if r is not None and encoding == "utf_8" and r.bom: encoding += "_sig" + if should_rename_legacy is False and encoding in CHARDET_CORRESPONDENCE: + encoding = CHARDET_CORRESPONDENCE[encoding] + return { - "encoding": encoding - if encoding not in CHARDET_CORRESPONDENCE - else CHARDET_CORRESPONDENCE[encoding], + "encoding": encoding, "language": language, "confidence": confidence, } diff --git a/lib/charset_normalizer/utils.py b/lib/charset_normalizer/utils.py index e3536267..76eafc64 100644 --- a/lib/charset_normalizer/utils.py +++ b/lib/charset_normalizer/utils.py @@ -311,7 +311,6 @@ def range_scan(decoded_sequence: str) -> List[str]: def cp_similarity(iana_name_a: str, iana_name_b: str) -> float: - if is_multi_byte_encoding(iana_name_a) or is_multi_byte_encoding(iana_name_b): return 0.0 @@ -351,7 +350,6 @@ def set_logging_handler( level: int = logging.INFO, format_string: str = "%(asctime)s | %(levelname)s | %(message)s", ) -> None: - logger = logging.getLogger(name) logger.setLevel(level) @@ -371,7 +369,6 @@ def cut_sequence_chunks( is_multi_byte_decoder: bool, decoded_payload: Optional[str] = None, ) -> Generator[str, None, None]: - if decoded_payload and is_multi_byte_decoder is False: for i in offsets: chunk = decoded_payload[i : i + chunk_size] @@ -397,7 +394,6 @@ def cut_sequence_chunks( # multi-byte bad cutting detector and adjustment # not the cleanest way to perform that fix but clever enough for now. if is_multi_byte_decoder and i > 0: - chunk_partial_size_chk: int = min(chunk_size, 16) if ( diff --git a/lib/charset_normalizer/version.py b/lib/charset_normalizer/version.py index cb503673..b74c2643 100644 --- a/lib/charset_normalizer/version.py +++ b/lib/charset_normalizer/version.py @@ -2,5 +2,5 @@ Expose version """ -__version__ = "3.0.1" +__version__ = "3.1.0" VERSION = __version__.split(".") diff --git a/lib/plexapi/__init__.py b/lib/plexapi/__init__.py index 2a7d39ef..eefc181d 100644 --- a/lib/plexapi/__init__.py +++ b/lib/plexapi/__init__.py @@ -23,7 +23,7 @@ X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bo # Plex Header Configuration X_PLEX_PROVIDES = CONFIG.get('header.provides', 'controller') -X_PLEX_PLATFORM = CONFIG.get('header.platform', CONFIG.get('header.platform', uname()[0])) +X_PLEX_PLATFORM = CONFIG.get('header.platform', uname()[0]) X_PLEX_PLATFORM_VERSION = CONFIG.get('header.platform_version', uname()[2]) X_PLEX_PRODUCT = CONFIG.get('header.product', PROJECT) X_PLEX_VERSION = CONFIG.get('header.version', VERSION) diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py index ff9e1e1a..e5455fc8 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -8,14 +8,14 @@ from plexapi.exceptions import BadRequest, NotFound from plexapi.mixins import ( AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin, ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin, - OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin, + AddedAtMixin, OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin, TrackArtistMixin, TrackDiscNumberMixin, TrackNumberMixin, CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin ) from plexapi.playlist import Playlist -class Audio(PlexPartialObject, PlayedUnplayedMixin): +class Audio(PlexPartialObject, PlayedUnplayedMixin, AddedAtMixin): """ Base class for all audio objects including :class:`~plexapi.audio.Artist`, :class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`. diff --git a/lib/plexapi/base.py b/lib/plexapi/base.py index fe6f0be0..9f888eed 100644 --- a/lib/plexapi/base.py +++ b/lib/plexapi/base.py @@ -695,38 +695,45 @@ class Playable: self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue - def getStreamURL(self, **params): + def getStreamURL(self, **kwargs): """ Returns a stream url that may be used by external applications such as VLC. Parameters: - **params (dict): optional parameters to manipulate the playback when accessing + **kwargs (dict): optional parameters to manipulate the playback when accessing the stream. A few known parameters include: maxVideoBitrate, videoResolution - offset, copyts, protocol, mediaIndex, platform. + offset, copyts, protocol, mediaIndex, partIndex, platform. Raises: :exc:`~plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL. """ if self.TYPE not in ('movie', 'episode', 'track', 'clip'): raise Unsupported(f'Fetching stream URL for {self.TYPE} is unsupported.') - mvb = params.get('maxVideoBitrate') - vr = params.get('videoResolution', '') + + mvb = kwargs.pop('maxVideoBitrate', None) + vr = kwargs.pop('videoResolution', '') + protocol = kwargs.pop('protocol', None) + params = { 'path': self.key, - 'offset': params.get('offset', 0), - 'copyts': params.get('copyts', 1), - 'protocol': params.get('protocol'), - 'mediaIndex': params.get('mediaIndex', 0), - 'X-Plex-Platform': params.get('platform', 'Chrome'), + 'mediaIndex': kwargs.pop('mediaIndex', 0), + 'partIndex': kwargs.pop('mediaIndex', 0), + 'protocol': protocol, + 'fastSeek': kwargs.pop('fastSeek', 1), + 'copyts': kwargs.pop('copyts', 1), + 'offset': kwargs.pop('offset', 0), 'maxVideoBitrate': max(mvb, 64) if mvb else None, - 'videoResolution': vr if re.match(r'^\d+x\d+$', vr) else None + 'videoResolution': vr if re.match(r'^\d+x\d+$', vr) else None, + 'X-Plex-Platform': kwargs.pop('platform', 'Chrome') } + params.update(kwargs) + # remove None values params = {k: v for k, v in params.items() if v is not None} streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video' - # sort the keys since the randomness fucks with my tests.. - sorted_params = sorted(params.items(), key=lambda val: val[0]) + ext = 'mpd' if protocol == 'dash' else 'm3u8' + return self._server.url( - f'/{streamtype}/:/transcode/universal/start.m3u8?{urlencode(sorted_params)}', + f'/{streamtype}/:/transcode/universal/start.{ext}?{urlencode(params)}', includeToken=True ) @@ -795,8 +802,8 @@ class Playable: """ Set the watched progress for this video. Note that setting the time to 0 will not work. - Use :func:`~plexapi.mixins.PlayedMixin.markPlayed` or - :func:`~plexapi.mixins.PlayedMixin.markUnplayed` to achieve + Use :func:`~plexapi.mixins.PlayedUnplayedMixin.markPlayed` or + :func:`~plexapi.mixins.PlayedUnplayedMixin.markUnplayed` to achieve that goal. Parameters: diff --git a/lib/plexapi/collection.py b/lib/plexapi/collection.py index 4561c158..9d754b1b 100644 --- a/lib/plexapi/collection.py +++ b/lib/plexapi/collection.py @@ -8,7 +8,7 @@ from plexapi.library import LibrarySection, ManagedHub from plexapi.mixins import ( AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin, ArtMixin, PosterMixin, ThemeMixin, - ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin, + AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin, LabelMixin ) from plexapi.utils import deprecated @@ -19,7 +19,7 @@ class Collection( PlexPartialObject, AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin, ArtMixin, PosterMixin, ThemeMixin, - ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin, + AddedAtMixin, ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin, LabelMixin ): """ Represents a single Collection. diff --git a/lib/plexapi/const.py b/lib/plexapi/const.py index 605ed78c..86659c57 100644 --- a/lib/plexapi/const.py +++ b/lib/plexapi/const.py @@ -4,6 +4,6 @@ # Library version MAJOR_VERSION = 4 MINOR_VERSION = 13 -PATCH_VERSION = 2 +PATCH_VERSION = 4 __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index c8ea8c4e..bf401ee0 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -399,6 +399,10 @@ class AudioStream(MediaPartStream): self.peak = utils.cast(float, data.attrib.get('peak')) self.startRamp = data.attrib.get('startRamp') + def setDefault(self): + """ Sets this audio stream as the default audio stream. """ + return self._parent().setDefaultAudioStream(self) + @utils.registerPlexObject class SubtitleStream(MediaPartStream): @@ -425,6 +429,10 @@ class SubtitleStream(MediaPartStream): self.headerCompression = data.attrib.get('headerCompression') self.transient = data.attrib.get('transient') + def setDefault(self): + """ Sets this subtitle stream as the default subtitle stream. """ + return self._parent().setDefaultSubtitleStream(self) + class LyricStream(MediaPartStream): """ Represents a lyric stream within a :class:`~plexapi.media.MediaPart`. @@ -1037,9 +1045,11 @@ class Marker(PlexObject): Attributes: TAG (str): 'Marker' end (int): The end time of the marker in milliseconds. + final (bool): True if the marker is the final credits marker. id (int): The ID of the marker. type (str): The type of marker. start (int): The start time of the marker in milliseconds. + version (int): The Plex marker version. """ TAG = 'Marker' @@ -1053,10 +1063,25 @@ class Marker(PlexObject): def _loadData(self, data): self._data = data self.end = utils.cast(int, data.attrib.get('endTimeOffset')) + self.final = utils.cast(bool, data.attrib.get('final')) self.id = utils.cast(int, data.attrib.get('id')) self.type = data.attrib.get('type') self.start = utils.cast(int, data.attrib.get('startTimeOffset')) + attributes = data.find('Attributes') + self.version = attributes.attrib.get('version') + + @property + def first(self): + """ Returns True if the marker in the first credits marker. """ + if self.type != 'credits': + return None + first = min( + (marker for marker in self._parent().markers if marker.type == 'credits'), + key=lambda m: m.start + ) + return first == self + @utils.registerPlexObject class Field(PlexObject): diff --git a/lib/plexapi/mixins.py b/lib/plexapi/mixins.py index 91c9caaa..b004ad74 100644 --- a/lib/plexapi/mixins.py +++ b/lib/plexapi/mixins.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from datetime import datetime -from urllib.parse import parse_qsl, quote_plus, unquote, urlencode, urlsplit +from urllib.parse import parse_qsl, quote, quote_plus, unquote, urlencode, urlsplit from plexapi import media, settings, utils from plexapi.exceptions import BadRequest, NotFound @@ -557,6 +557,24 @@ class EditFieldMixin: return self._edit(**edits) +class AddedAtMixin(EditFieldMixin): + """ Mixin for Plex objects that can have an added at date. """ + + def editAddedAt(self, addedAt, locked=True): + """ Edit the added at date. + + Parameters: + addedAt (int or str or datetime): The new value as a unix timestamp (int), + "YYYY-MM-DD" (str), or datetime object. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + if isinstance(addedAt, str): + addedAt = int(round(datetime.strptime(addedAt, '%Y-%m-%d').timestamp())) + elif isinstance(addedAt, datetime): + addedAt = int(round(addedAt.timestamp())) + return self.editField('addedAt', addedAt, locked=locked) + + class ContentRatingMixin(EditFieldMixin): """ Mixin for Plex objects that can have a content rating. """ @@ -590,7 +608,7 @@ class OriginallyAvailableMixin(EditFieldMixin): """ Edit the originally available date. Parameters: - originallyAvailable (str or datetime): The new value (YYYY-MM-DD) or datetime object. + originallyAvailable (str or datetime): The new value "YYYY-MM-DD (str) or datetime object. locked (bool): True (default) to lock the field, False to unlock the field. """ if isinstance(originallyAvailable, datetime): @@ -726,7 +744,7 @@ class PhotoCapturedTimeMixin(EditFieldMixin): """ Edit the photo captured time. Parameters: - capturedTime (str or datetime): The new value (YYYY-MM-DD hh:mm:ss) or datetime object. + capturedTime (str or datetime): The new value "YYYY-MM-DD hh:mm:ss" (str) or datetime object. locked (bool): True (default) to lock the field, False to unlock the field. """ if isinstance(capturedTime, datetime): @@ -804,7 +822,7 @@ class EditTagsMixin: if remove: tagname = f'{tag}[].tag.tag-' - data[tagname] = ','.join([str(t) for t in items]) + data[tagname] = ','.join([quote(str(t)) for t in items]) else: for i, item in enumerate(items): tagname = f'{str(tag)}[{i}].tag.tag' diff --git a/lib/plexapi/photo.py b/lib/plexapi/photo.py index 28556650..4c3d89b5 100644 --- a/lib/plexapi/photo.py +++ b/lib/plexapi/photo.py @@ -8,7 +8,7 @@ from plexapi.exceptions import BadRequest from plexapi.mixins import ( RatingMixin, ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, - SortTitleMixin, SummaryMixin, TitleMixin, PhotoCapturedTimeMixin, + AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin, PhotoCapturedTimeMixin, TagMixin ) @@ -18,7 +18,7 @@ class Photoalbum( PlexPartialObject, RatingMixin, ArtMixin, PosterMixin, - SortTitleMixin, SummaryMixin, TitleMixin + AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin ): """ Represents a single Photoalbum (collection of photos). @@ -146,7 +146,7 @@ class Photo( PlexPartialObject, Playable, RatingMixin, ArtUrlMixin, PosterUrlMixin, - PhotoCapturedTimeMixin, SortTitleMixin, SummaryMixin, TitleMixin, + AddedAtMixin, PhotoCapturedTimeMixin, SortTitleMixin, SummaryMixin, TitleMixin, TagMixin ): """ Represents a single Photo. diff --git a/lib/plexapi/server.py b/lib/plexapi/server.py index bec9fc08..4a1a51ce 100644 --- a/lib/plexapi/server.py +++ b/lib/plexapi/server.py @@ -827,7 +827,7 @@ class PlexServer(PlexObject): return notifier def transcodeImage(self, imageUrl, height, width, - opacity=None, saturation=None, blur=None, background=None, + opacity=None, saturation=None, blur=None, background=None, blendColor=None, minSize=True, upscale=True, imageFormat=None): """ Returns the URL for a transcoded image. @@ -842,6 +842,7 @@ class PlexServer(PlexObject): saturation (int, optional): Change the saturation of the image (0 to 100). blur (int, optional): The blur to apply to the image in pixels (e.g. 3). background (str, optional): The background hex colour to apply behind the opacity (e.g. '000000'). + blendColor (str, optional): The hex colour to blend the image with (e.g. '000000'). minSize (bool, optional): Maintain smallest dimension. Default True. upscale (bool, optional): Upscale the image if required. Default True. imageFormat (str, optional): 'jpeg' (default) or 'png'. @@ -861,6 +862,8 @@ class PlexServer(PlexObject): params['blur'] = blur if background is not None: params['background'] = str(background).strip('#') + if blendColor is not None: + params['blendColor'] = str(blendColor).strip('#') if imageFormat is not None: params['format'] = imageFormat.lower() diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index f429147e..d7d5afd1 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -53,6 +53,8 @@ SEARCHTYPES = { 'optimizedVersion': 42, 'userPlaylistItem': 1001, } +REVERSESEARCHTYPES = {v: k for k, v in SEARCHTYPES.items()} + # Tag Types - Plex uses these to filter specific tags when searching. TAGTYPES = { 'tag': 0, @@ -91,6 +93,8 @@ TAGTYPES = { 'network': 319, 'place': 400, } +REVERSETAGTYPES = {v: k for k, v in TAGTYPES.items()} + # Plex Objects - Populated at runtime PLEXOBJECTS = {} @@ -219,11 +223,12 @@ def searchType(libtype): :exc:`~plexapi.exceptions.NotFound`: Unknown libtype """ libtype = str(libtype) - if libtype in [str(v) for v in SEARCHTYPES.values()]: - return libtype - if SEARCHTYPES.get(libtype) is not None: + try: return SEARCHTYPES[libtype] - raise NotFound(f'Unknown libtype: {libtype}') + except KeyError: + if libtype in [str(k) for k in REVERSESEARCHTYPES]: + return libtype + raise NotFound(f'Unknown libtype: {libtype}') from None def reverseSearchType(libtype): @@ -235,13 +240,12 @@ def reverseSearchType(libtype): Raises: :exc:`~plexapi.exceptions.NotFound`: Unknown libtype """ - if libtype in SEARCHTYPES: - return libtype - libtype = int(libtype) - for k, v in SEARCHTYPES.items(): - if libtype == v: - return k - raise NotFound(f'Unknown libtype: {libtype}') + try: + return REVERSESEARCHTYPES[int(libtype)] + except (KeyError, ValueError): + if libtype in SEARCHTYPES: + return libtype + raise NotFound(f'Unknown libtype: {libtype}') from None def tagType(tag): @@ -254,11 +258,12 @@ def tagType(tag): :exc:`~plexapi.exceptions.NotFound`: Unknown tag """ tag = str(tag) - if tag in [str(v) for v in TAGTYPES.values()]: - return tag - if TAGTYPES.get(tag) is not None: + try: return TAGTYPES[tag] - raise NotFound(f'Unknown tag: {tag}') + except KeyError: + if tag in [str(k) for k in REVERSETAGTYPES]: + return tag + raise NotFound(f'Unknown tag: {tag}') from None def reverseTagType(tag): @@ -270,13 +275,12 @@ def reverseTagType(tag): Raises: :exc:`~plexapi.exceptions.NotFound`: Unknown tag """ - if tag in TAGTYPES: - return tag - tag = int(tag) - for k, v in TAGTYPES.items(): - if tag == v: - return k - raise NotFound(f'Unknown tag: {tag}') + try: + return REVERSETAGTYPES[int(tag)] + except (KeyError, ValueError): + if tag in TAGTYPES: + return tag + raise NotFound(f'Unknown tag: {tag}') from None def threaded(callback, listargs): diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index fe12ce67..d7f4d665 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -8,14 +8,14 @@ from plexapi.exceptions import BadRequest from plexapi.mixins import ( AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin, ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin, - ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin, - SummaryMixin, TaglineMixin, TitleMixin, + AddedAtMixin, ContentRatingMixin, EditionTitleMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, + StudioMixin, SummaryMixin, TaglineMixin, TitleMixin, CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin, WatchlistMixin ) -class Video(PlexPartialObject, PlayedUnplayedMixin): +class Video(PlexPartialObject, PlayedUnplayedMixin, AddedAtMixin): """ Base class for all video objects including :class:`~plexapi.video.Movie`, :class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`, :class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`. @@ -97,10 +97,25 @@ class Video(PlexPartialObject, PlayedUnplayedMixin): """ Returns str, default title for a new syncItem. """ return self.title + def videoStreams(self): + """ Returns a list of :class:`~plexapi.media.videoStream` objects for all MediaParts. """ + streams = [] + + if self.isPartialObject(): + self.reload() + + parts = self.iterParts() + for part in parts: + streams += part.videoStreams() + return streams + def audioStreams(self): """ Returns a list of :class:`~plexapi.media.AudioStream` objects for all MediaParts. """ streams = [] + if self.isPartialObject(): + self.reload() + parts = self.iterParts() for part in parts: streams += part.audioStreams() @@ -110,6 +125,9 @@ class Video(PlexPartialObject, PlayedUnplayedMixin): """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """ streams = [] + if self.isPartialObject(): + self.reload() + parts = self.iterParts() for part in parts: streams += part.subtitleStreams() @@ -311,11 +329,13 @@ class Movie( directors (List<:class:`~plexapi.media.Director`>): List of director objects. duration (int): Duration of the movie in milliseconds. editionTitle (str): The edition title of the movie (e.g. Director's Cut, Extended Edition, etc.). + enableCreditsMarkerGeneration (int): Setting that indicates if credits markers detection is enabled. genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. labels (List<:class:`~plexapi.media.Label`>): List of label objects. languageOverride (str): Setting that indicates if a language is used to override metadata (eg. en-CA, None = Library default). + markers (List<:class:`~plexapi.media.Marker`>): List of marker objects. media (List<:class:`~plexapi.media.Media`>): List of media objects. originallyAvailableAt (datetime): Datetime the movie was released. originalTitle (str): Original title, often the foreign title (転々; 엽기적인 그녀). @@ -353,10 +373,12 @@ class Movie( self.directors = self.findItems(data, media.Director) self.duration = utils.cast(int, data.attrib.get('duration')) self.editionTitle = data.attrib.get('editionTitle') + self.enableCreditsMarkerGeneration = utils.cast(int, data.attrib.get('enableCreditsMarkerGeneration', '-1')) self.genres = self.findItems(data, media.Genre) self.guids = self.findItems(data, media.Guid) self.labels = self.findItems(data, media.Label) self.languageOverride = data.attrib.get('languageOverride') + self.markers = self.findItems(data, media.Marker) self.media = self.findItems(data, media.Media) self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.originalTitle = data.attrib.get('originalTitle') @@ -390,6 +412,11 @@ class Movie( """ return [part.file for part in self.iterParts() if part] + @property + def hasCreditsMarker(self): + """ Returns True if the movie has a credits marker. """ + return any(marker.type == 'credits' for marker in self.markers) + @property def hasPreviewThumbnails(self): """ Returns True if any of the media parts has generated preview (BIF) thumbnails. """ @@ -432,6 +459,7 @@ class Show( TYPE (str): 'show' audienceRating (float): Audience rating (TMDB or TVDB). audienceRatingImage (str): Key to audience rating image (tmdb://image.rating). + audioLanguage (str): Setting that indicates the preferred audio language. autoDeletionItemPolicyUnwatchedLibrary (int): Setting that indicates the number of unplayed episodes to keep for the show (0 = All episodes, 5 = 5 latest episodes, 3 = 3 latest episodes, 1 = 1 latest episode, -3 = Episodes added in the past 3 days, -7 = Episodes added in the @@ -440,10 +468,11 @@ class Show( after being watched for the show (0 = Never, 1 = After a day, 7 = After a week, 100 = On next refresh). banner (str): Key to banner artwork (/library/metadata//banner/). - childCount (int): Number of seasons in the show. + childCount (int): Number of seasons (including Specials) in the show. collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. contentRating (str) Content rating (PG-13; NR; TV-G). duration (int): Typical duration of the show episodes in milliseconds. + enableCreditsMarkerGeneration (int): Setting that indicates if credits markers detection is enabled. episodeSort (int): Setting that indicates how episodes are sorted for the show (-1 = Library default, 0 = Oldest first, 1 = Newest first). flattenSeasons (int): Setting that indicates if seasons are set to hidden for the show @@ -463,10 +492,14 @@ class Show( rating (float): Show rating (7.9; 9.8; 8.1). ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects. roles (List<:class:`~plexapi.media.Role`>): List of role objects. + seasonCount (int): Number of seasons (excluding Specials) in the show. showOrdering (str): Setting that indicates the episode ordering for the show (None = Library default). similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects. studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment). + subtitleLanguage (str): Setting that indicates the preferred subtitle language. + subtitleMode (int): Setting that indicates the auto-select subtitle mode. + (-1 = Account default, 0 = Manually selected, 1 = Shown with foreign audio, 2 = Always enabled). tagline (str): Show tag line. theme (str): URL to theme resource (/library/metadata//theme/). useOriginalTitle (int): Setting that indicates if the original title is used for the show @@ -483,6 +516,7 @@ class Show( Video._loadData(self, data) self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) self.audienceRatingImage = data.attrib.get('audienceRatingImage') + self.audioLanguage = data.attrib.get('audioLanguage', '') self.autoDeletionItemPolicyUnwatchedLibrary = utils.cast( int, data.attrib.get('autoDeletionItemPolicyUnwatchedLibrary', '0')) self.autoDeletionItemPolicyWatchedLibrary = utils.cast( @@ -492,6 +526,7 @@ class Show( self.collections = self.findItems(data, media.Collection) self.contentRating = data.attrib.get('contentRating') self.duration = utils.cast(int, data.attrib.get('duration')) + self.enableCreditsMarkerGeneration = utils.cast(int, data.attrib.get('enableCreditsMarkerGeneration', '-1')) self.episodeSort = utils.cast(int, data.attrib.get('episodeSort', '-1')) self.flattenSeasons = utils.cast(int, data.attrib.get('flattenSeasons', '-1')) self.genres = self.findItems(data, media.Genre) @@ -508,9 +543,12 @@ class Show( self.rating = utils.cast(float, data.attrib.get('rating')) self.ratings = self.findItems(data, media.Rating) self.roles = self.findItems(data, media.Role) + self.seasonCount = utils.cast(int, data.attrib.get('seasonCount', self.childCount)) self.showOrdering = data.attrib.get('showOrdering') self.similar = self.findItems(data, media.Similar) self.studio = data.attrib.get('studio') + self.subtitleLanguage = data.attrib.get('audioLanguage', '') + self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1')) self.tagline = data.attrib.get('tagline') self.theme = data.attrib.get('theme') self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1')) @@ -619,7 +657,7 @@ class Show( @utils.registerPlexObject class Season( Video, - ExtrasMixin, RatingMixin, + AdvancedSettingsMixin, ExtrasMixin, RatingMixin, ArtMixin, PosterMixin, ThemeUrlMixin, SummaryMixin, TitleMixin, CollectionMixin, LabelMixin @@ -629,6 +667,7 @@ class Season( Attributes: TAG (str): 'Directory' TYPE (str): 'season' + audioLanguage (str): Setting that indicates the preferred audio language. collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. index (int): Season number. @@ -644,6 +683,9 @@ class Season( parentThumb (str): URL to show thumbnail image (/library/metadata//thumb/). parentTitle (str): Name of the show for the season. ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects. + subtitleLanguage (str): Setting that indicates the preferred subtitle language. + subtitleMode (int): Setting that indicates the auto-select subtitle mode. + (-1 = Series default, 0 = Manually selected, 1 = Shown with foreign audio, 2 = Always enabled). viewedLeafCount (int): Number of items marked as played in the season view. year (int): Year the season was released. """ @@ -654,6 +696,7 @@ class Season( def _loadData(self, data): """ Load attribute values from Plex XML response. """ Video._loadData(self, data) + self.audioLanguage = data.attrib.get('audioLanguage', '') self.collections = self.findItems(data, media.Collection) self.guids = self.findItems(data, media.Guid) self.index = utils.cast(int, data.attrib.get('index')) @@ -669,6 +712,8 @@ class Season( self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') self.ratings = self.findItems(data, media.Rating) + self.subtitleLanguage = data.attrib.get('audioLanguage', '') + self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1')) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.year = utils.cast(int, data.attrib.get('year')) @@ -918,14 +963,19 @@ class Episode( @property def hasCommercialMarker(self): - """ Returns True if the episode has a commercial marker in the xml. """ + """ Returns True if the episode has a commercial marker. """ return any(marker.type == 'commercial' for marker in self.markers) @property def hasIntroMarker(self): - """ Returns True if the episode has an intro marker in the xml. """ + """ Returns True if the episode has an intro marker. """ return any(marker.type == 'intro' for marker in self.markers) + @property + def hasCreditsMarker(self): + """ Returns True if the episode has a credits marker. """ + return any(marker.type == 'credits' for marker in self.markers) + @property def hasPreviewThumbnails(self): """ Returns True if any of the media parts has generated preview (BIF) thumbnails. """ diff --git a/requirements.txt b/requirements.txt index cb019452..df45bd68 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ MarkupSafe==2.1.2 musicbrainzngs==0.7.1 packaging==23.0 paho-mqtt==1.6.1 -plexapi==4.13.2 +plexapi==4.13.4 portend==3.1.0 profilehooks==1.12.0 PyJWT==2.6.0