Repository: pkkid/python-plexapi

Issue:
Blank Collections now throw an error when editing
### Describe the Bug

So i know plex isnt supposed to be able to handle blank collections but they can be created. When you attempt to edit the details of one you get the error below.

tracing the error down its because of the new [`_validateItems` Method](https://github.com/pkkid/python-plexapi/blob/61c805ff6b23e4bfa66fe99300d41997d4046536/plexapi/library.py#L1685)

When the `not items` is checked against an empty collections its evaluated to True because `__len__` returns 0.

### Code Snippets

```python
File "\modules\builder.py", line 3003, in update_details
  self.obj.editSummary(summary[1])
File "\plexapi\mixins.py", line 628, in editSummary
  return self.editField('summary', summary, locked=locked)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "\plexapi\mixins.py", line 517, in editField
  return self._edit(**edits)
         ^^^^^^^^^^^^^^^^^^^
File "\plexapi\base.py", line 577, in _edit
  self.section()._edit(items=self, **kwargs)
File "\plexapi\library.py", line 1721, in _edit
  kwargs['id'] = ','.join(str(item.ratingKey) for item in self._validateItems(items))
                                                          ^^^^^^^^^^^^^^^^^^^^^^^^^^
File "\plexapi\library.py", line 1692, in _validateItems
  raise BadRequest('No items specified.')
```


### Expected Behavior

_No response_

### Additional Context

Empty Playlists could also be effected but i haven't tested anything with them 

### Operating System and Version

Windows 11

### Plex Media Server Version

1.32.5.7349

### Python Version

3.11.1

### PlexAPI Version

4.15.0

Discussion:

Patch:
diff --git a/plexapi/audio.py b/plexapi/audio.py
index e138276..2a16987 100644
--- a/plexapi/audio.py
+++ b/plexapi/audio.py
@@ -1,5 +1,6 @@
 # -*- coding: utf-8 -*-
 import os
+from pathlib import Path
 from urllib.parse import quote_plus
 
 from plexapi import media, utils
@@ -240,6 +241,12 @@ class Artist(
         key = f'{self.key}?includeStations=1'
         return next(iter(self.fetchItems(key, cls=Playlist, rtag="Stations")), None)
 
+    @property
+    def metadataDirectory(self):
+        """ Returns the Plex Media Server data directory where the metadata is stored. """
+        guid_hash = utils.sha1hash(self.guid)
+        return str(Path('Metadata') / 'Artists' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
+
 
 @utils.registerPlexObject
 class Album(
@@ -359,6 +366,12 @@ class Album(
         """ Returns str, default title for a new syncItem. """
         return f'{self.parentTitle} - {self.title}'
 
+    @property
+    def metadataDirectory(self):
+        """ Returns the Plex Media Server data directory where the metadata is stored. """
+        guid_hash = utils.sha1hash(self.guid)
+        return str(Path('Metadata') / 'Albums' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
+
 
 @utils.registerPlexObject
 class Track(
@@ -470,6 +483,12 @@ class Track(
         """ Get the Plex Web URL with the correct parameters. """
         return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey)
 
+    @property
+    def metadataDirectory(self):
+        """ Returns the Plex Media Server data directory where the metadata is stored. """
+        guid_hash = utils.sha1hash(self.parentGuid)
+        return str(Path('Metadata') / 'Albums' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
+
 
 @utils.registerPlexObject
 class TrackSession(PlexSession, Track):
diff --git a/plexapi/client.py b/plexapi/client.py
index 2b4283c..2aebc27 100644
--- a/plexapi/client.py
+++ b/plexapi/client.py
@@ -94,7 +94,7 @@ class PlexClient(PlexObject):
             raise Unsupported('Cannot reload an object not built from a URL.')
         self._initpath = self.key
         data = self.query(self.key, timeout=timeout)
-        if not data:
+        if data is None:
             raise NotFound(f"Client not found at {self._baseurl}")
         if self._clientIdentifier:
             client = next(
diff --git a/plexapi/collection.py b/plexapi/collection.py
index d4820fe..34940b9 100644
--- a/plexapi/collection.py
+++ b/plexapi/collection.py
@@ -1,4 +1,5 @@
 # -*- coding: utf-8 -*-
+from pathlib import Path
 from urllib.parse import quote_plus
 
 from plexapi import media, utils
@@ -560,3 +561,9 @@ class Collection(
             raise Unsupported('Unsupported collection content')
 
         return myplex.sync(sync_item, client=client, clientId=clientId)
+
+    @property
+    def metadataDirectory(self):
+        """ Returns the Plex Media Server data directory where the metadata is stored. """
+        guid_hash = utils.sha1hash(self.guid)
+        return str(Path('Metadata') / 'Collections' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
diff --git a/plexapi/library.py b/plexapi/library.py
index cbca424..eb26674 100644
--- a/plexapi/library.py
+++ b/plexapi/library.py
@@ -1684,7 +1684,7 @@ class LibrarySection(PlexObject):
 
     def _validateItems(self, items):
         """ Validates the specified items are from this library and of the same type. """
-        if not items:
+        if items is None or items == []:
             raise BadRequest('No items specified.')
         
         if not isinstance(items, list):
diff --git a/plexapi/media.py b/plexapi/media.py
index 8793463..c841231 100644
--- a/plexapi/media.py
+++ b/plexapi/media.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
-
 import xml
+from pathlib import Path
 from urllib.parse import quote_plus
 
 from plexapi import log, settings, utils
@@ -121,6 +121,7 @@ class MediaPart(PlexObject):
             optimizedForStreaming (bool): True if the file is optimized for streaming.
             packetLength (int): The packet length of the file.
             requiredBandwidths (str): The required bandwidths to stream the file.
+            selected (bool): True if this media part is selected.
             size (int): The size of the file in bytes (ex: 733884416).
             streams (List<:class:`~plexapi.media.MediaPartStream`>): List of stream objects.
             syncItemId (int): The unique ID for this media part if it is synced.
@@ -184,38 +185,60 @@ class MediaPart(PlexObject):
         """ Returns a list of :class:`~plexapi.media.LyricStream` objects in this MediaPart. """
         return [stream for stream in self.streams if isinstance(stream, LyricStream)]
 
-    def setDefaultAudioStream(self, stream):
-        """ Set the default :class:`~plexapi.media.AudioStream` for this MediaPart.
+    def setSelectedAudioStream(self, stream):
+        """ Set the selected :class:`~plexapi.media.AudioStream` for this MediaPart.
 
             Parameters:
-                stream (:class:`~plexapi.media.AudioStream`): AudioStream to set as default
+                stream (:class:`~plexapi.media.AudioStream`): Audio stream to set as selected
         """
+        key = f'/library/parts/{self.id}'
+        params = {'allParts': 1}
+
         if isinstance(stream, AudioStream):
-            key = f"/library/parts/{self.id}?audioStreamID={stream.id}&allParts=1"
+            params['audioStreamID'] = stream.id
         else:
-            key = f"/library/parts/{self.id}?audioStreamID={stream}&allParts=1"
-        self._server.query(key, method=self._server._session.put)
+            params['audioStreamID'] = stream
+
+        self._server.query(key, method=self._server._session.put, params=params)
         return self
 
-    def setDefaultSubtitleStream(self, stream):
-        """ Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart.
+    def setSelectedSubtitleStream(self, stream):
+        """ Set the selected :class:`~plexapi.media.SubtitleStream` for this MediaPart.
 
             Parameters:
-                stream (:class:`~plexapi.media.SubtitleStream`): SubtitleStream to set as default.
+                stream (:class:`~plexapi.media.SubtitleStream`): Subtitle stream to set as selected.
         """
+        key = f'/library/parts/{self.id}'
+        params = {'allParts': 1}
+
         if isinstance(stream, SubtitleStream):
-            key = f"/library/parts/{self.id}?subtitleStreamID={stream.id}&allParts=1"
+            params['subtitleStreamID'] = stream.id
         else:
-            key = f"/library/parts/{self.id}?subtitleStreamID={stream}&allParts=1"
+            params['subtitleStreamID'] = stream
+
         self._server.query(key, method=self._server._session.put)
         return self
 
-    def resetDefaultSubtitleStream(self):
-        """ Set default subtitle of this MediaPart to 'none'. """
-        key = f"/library/parts/{self.id}?subtitleStreamID=0&allParts=1"
-        self._server.query(key, method=self._server._session.put)
+    def resetSelectedSubtitleStream(self):
+        """ Set the selected subtitle of this MediaPart to 'None'. """
+        key = f'/library/parts/{self.id}'
+        params = {'subtitleStreamID': 0, 'allParts': 1}
+
+        self._server.query(key, method=self._server._session.put, params=params)
         return self
 
+    @deprecated('Use "setSelectedAudioStream" instead.')
+    def setDefaultAudioStream(self, stream):
+        return self.setSelectedAudioStream(stream)
+
+    @deprecated('Use "setSelectedSubtitleStream" instead.')
+    def setDefaultSubtitleStream(self, stream):
+        return self.setSelectedSubtitleStream(stream)
+
+    @deprecated('Use "resetSelectedSubtitleStream" instead.')
+    def resetDefaultSubtitleStream(self):
+        return self.resetSelectedSubtitleStream()
+
 
 class MediaPartStream(PlexObject):
     """ Base class for media streams. These consist of video, audio, subtitles, and lyrics.
@@ -399,9 +422,15 @@ class AudioStream(MediaPartStream):
             self.peak = utils.cast(float, data.attrib.get('peak'))
             self.startRamp = data.attrib.get('startRamp')
 
+    def setSelected(self):
+        """ Sets this audio stream as the selected audio stream.
+            Alias for :func:`~plexapi.media.MediaPart.setSelectedAudioStream`.
+        """
+        return self._parent().setSelectedAudioStream(self)
+
+    @deprecated('Use "setSelected" instead.')
     def setDefault(self):
-        """ Sets this audio stream as the default audio stream. """
-        return self._parent().setDefaultAudioStream(self)
+        return self.setSelected()
 
 
 @utils.registerPlexObject
@@ -437,9 +466,15 @@ class SubtitleStream(MediaPartStream):
         self.transient = data.attrib.get('transient')
         self.userID = utils.cast(int, data.attrib.get('userID'))
 
+    def setSelected(self):
+        """ Sets this subtitle stream as the selected subtitle stream.
+            Alias for :func:`~plexapi.media.MediaPart.setSelectedSubtitleStream`.
+        """
+        return self._parent().setSelectedSubtitleStream(self)
+
+    @deprecated('Use "setSelected" instead.')
     def setDefault(self):
-        """ Sets this subtitle stream as the default subtitle stream. """
-        return self._parent().setDefaultSubtitleStream(self)
+        return self.setSelected()
 
 
 class LyricStream(MediaPartStream):
@@ -989,6 +1024,20 @@ class BaseResource(PlexObject):
         except xml.etree.ElementTree.ParseError:
             pass
 
+    @property
+    def resourceFilepath(self):
+        """ Returns the file path to the resource in the Plex Media Server data directory.
+            Note: Returns the URL if the resource is not stored locally.
+        """
+        if self.ratingKey.startswith('media://'):
+            return str(Path('Media') / 'localhost' / self.ratingKey.split('://')[-1])
+        elif self.ratingKey.startswith('metadata://'):
+            return str(Path(self._parent().metadataDirectory) / 'Contents' / '_combined' / self.ratingKey.split('://')[-1])
+        elif self.ratingKey.startswith('upload://'):
+            return str(Path(self._parent().metadataDirectory) / 'Uploads' / self.ratingKey.split('://')[-1])
+        else:
+            return self.ratingKey
+
 
 class Art(BaseResource):
     """ Represents a single Art object. """
diff --git a/plexapi/myplex.py b/plexapi/myplex.py
index c90b5d3..45f1fb8 100644
--- a/plexapi/myplex.py
+++ b/plexapi/myplex.py
@@ -239,8 +239,10 @@ class MyPlexAccount(PlexObject):
                 raise Unauthorized(message)
             else:
                 raise BadRequest(message)
-        if headers.get('Accept') == 'application/json':
+        if 'application/json' in response.headers.get('Content-Type', ''):
             return response.json()
+        elif 'text/plain' in response.headers.get('Content-Type', ''):
+            return response.text.strip()
         data = response.text.encode('utf8')
         return ElementTree.fromstring(data) if data.strip() else None
 
@@ -1135,6 +1137,21 @@ class MyPlexAccount(PlexObject):
 
         return objs
 
+    def publicIP(self):
+        """ Returns your public IP address. """
+        return self.query('https://plex.tv/:/ip')
+
+    def geoip(self, ip_address):
+        """ Returns a :class:`~plexapi.myplex.GeoLocation` object with geolocation information
+            for an IP address using Plex's GeoIP database.
+
+            Parameters:
+                ip_address (str): IP address to lookup.
+        """
+        params = {'ip_address': ip_address}
+        data = self.query('https://plex.tv/api/v2/geoip', params=params)
+        return GeoLocation(self, data)
+
 
 class MyPlexUser(PlexObject):
     """ This object represents non-signed in users such as friends and linked
@@ -1773,7 +1790,7 @@ class MyPlexPinLogin:
             params = None
 
         response = self._query(url, self._session.post, params=params)
-        if not response:
+        if response is None:
             return None
 
         self._id = response.attrib.get('id')
@@ -1790,7 +1807,7 @@ class MyPlexPinLogin:
 
         url = self.CHECKPINS.format(pinid=self._id)
         response = self._query(url)
-        if not response:
+        if response is None:
             return False
 
         token = response.attrib.get('authToken')
@@ -1964,3 +1981,42 @@ class UserState(PlexObject):
         self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
         self.viewState = data.attrib.get('viewState') == 'complete'
         self.watchlistedAt = utils.toDatetime(data.attrib.get('watchlistedAt'))
+
+
+class GeoLocation(PlexObject):
+    """ Represents a signle IP address geolocation
+
+        Attributes:
+            TAG (str): location
+            city (str): City name
+            code (str): Country code
+            continentCode (str): Continent code
+            coordinates (Tuple<float>): Latitude and longitude
+            country (str): Country name
+            europeanUnionMember (bool): True if the country is a member of the European Union
+            inPrivacyRestrictedCountry (bool): True if the country is privacy restricted
+            postalCode (str): Postal code
+            subdivisions (str): Subdivision name
+            timezone (str): Timezone
+    """
+    TAG = 'location'
+
+    def _loadData(self, data):
+        self._data = data
+        self.city = data.attrib.get('city')
+        self.code = data.attrib.get('code')
+        self.continentCode = data.attrib.get('continent_code')
+        self.coordinates = tuple(
+            utils.cast(float, coord) for coord in (data.attrib.get('coordinates') or ',').split(','))
+        self.country = data.attrib.get('country')
+        self.postalCode = data.attrib.get('postal_code')
+        self.subdivisions = data.attrib.get('subdivisions')
+        self.timezone = data.attrib.get('time_zone')
+
+        europeanUnionMember = data.attrib.get('european_union_member')
+        self.europeanUnionMember = (
+            False if europeanUnionMember == 'Unknown' else utils.cast(bool, europeanUnionMember))
+
+        inPrivacyRestrictedCountry = data.attrib.get('in_privacy_restricted_country')
+        self.inPrivacyRestrictedCountry = (
+            False if inPrivacyRestrictedCountry == 'Unknown' else utils.cast(bool, inPrivacyRestrictedCountry))
diff --git a/plexapi/photo.py b/plexapi/photo.py
index 039ac80..7fcedcf 100644
--- a/plexapi/photo.py
+++ b/plexapi/photo.py
@@ -1,5 +1,6 @@
 # -*- coding: utf-8 -*-
 import os
+from pathlib import Path
 from urllib.parse import quote_plus
 
 from plexapi import media, utils, video
@@ -139,6 +140,12 @@ class Photoalbum(
         """ Get the Plex Web URL with the correct parameters. """
         return self._server._buildWebURL(base=base, endpoint='details', key=self.key, legacy=1)
 
+    @property
+    def metadataDirectory(self):
+        """ Returns the Plex Media Server data directory where the metadata is stored. """
+        guid_hash = utils.sha1hash(self.guid)
+        return str(Path('Metadata') / 'Photos' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
+
 
 @utils.registerPlexObject
 class Photo(
@@ -290,6 +297,12 @@ class Photo(
         """ Get the Plex Web URL with the correct parameters. """
         return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey, legacy=1)
 
+    @property
+    def metadataDirectory(self):
+        """ Returns the Plex Media Server data directory where the metadata is stored. """
+        guid_hash = utils.sha1hash(self.parentGuid)
+        return str(Path('Metadata') / 'Photos' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
+
 
 @utils.registerPlexObject
 class PhotoSession(PlexSession, Photo):
diff --git a/plexapi/playlist.py b/plexapi/playlist.py
index c435613..4b5eac5 100644
--- a/plexapi/playlist.py
+++ b/plexapi/playlist.py
@@ -1,5 +1,6 @@
 # -*- coding: utf-8 -*-
 import re
+from pathlib import Path
 from urllib.parse import quote_plus, unquote
 
 from plexapi import media, utils
@@ -496,3 +497,9 @@ class Playlist(
     def _getWebURL(self, base=None):
         """ Get the Plex Web URL with the correct parameters. """
         return self._server._buildWebURL(base=base, endpoint='playlist', key=self.key)
+
+    @property
+    def metadataDirectory(self):
+        """ Returns the Plex Media Server data directory where the metadata is stored. """
+        guid_hash = utils.sha1hash(self.guid)
+        return str(Path('Metadata') / 'Playlists' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
diff --git a/plexapi/server.py b/plexapi/server.py
index 69d5f89..ebb7157 100644
--- a/plexapi/server.py
+++ b/plexapi/server.py
@@ -189,6 +189,11 @@ class PlexServer(PlexObject):
         data = self.query(Settings.key)
         return Settings(self, data)
 
+    def identity(self):
+        """ Returns the Plex server identity. """
+        data = self.query('/identity')
+        return Identity(self, data)
+
     def account(self):
         """ Returns the :class:`~plexapi.server.Account` object this server belongs to. """
         data = self.query(Account.key)
@@ -1273,3 +1278,22 @@ class ButlerTask(PlexObject):
         self.name = data.attrib.get('name')
         self.scheduleRandomized = utils.cast(bool, data.attrib.get('scheduleRandomized'))
         self.title = data.attrib.get('title')
+
+
+class Identity(PlexObject):
+    """ Represents a server identity.
+
+        Attributes:
+            claimed (bool): True or False if the server is claimed.
+            machineIdentifier (str): The Plex server machine identifier.
+            version (str): The Plex server version.
+    """
+
+    def __repr__(self):
+        return f"<{self.__class__.__name__}:{self.machineIdentifier}>"
+    
+    def _loadData(self, data):
+        self._data = data
+        self.claimed = utils.cast(bool, data.attrib.get('claimed'))
+        self.machineIdentifier = data.attrib.get('machineIdentifier')
+        self.version = data.attrib.get('version')
diff --git a/plexapi/utils.py b/plexapi/utils.py
index d1882fb..558f052 100644
--- a/plexapi/utils.py
+++ b/plexapi/utils.py
@@ -11,13 +11,14 @@ import unicodedata
 import warnings
 import zipfile
 from collections import deque
-from datetime import datetime
+from datetime import datetime, timedelta
 from getpass import getpass
+from hashlib import sha1
 from threading import Event, Thread
 from urllib.parse import quote
-from requests.status_codes import _codes as codes
 
 import requests
+from requests.status_codes import _codes as codes
 
 from plexapi.exceptions import BadRequest, NotFound, Unauthorized
 
@@ -313,33 +314,39 @@ def toDatetime(value, format=None):
             value (str): value to return as a datetime
             format (str): Format to pass strftime (optional; if value is a str).
     """
-    if value and value is not None:
+    if value is not None:
         if format:
             try:
-                value = datetime.strptime(value, format)
+                return datetime.strptime(value, format)
             except ValueError:
-                log.info('Failed to parse %s to datetime, defaulting to None', value)
+                log.info('Failed to parse "%s" to datetime as format "%s", defaulting to None', value, format)
                 return None
         else:
-            # https://bugs.python.org/issue30684
-            # And platform support for before epoch seems to be flaky.
-            # Also limit to max 32-bit integer
-            value = min(max(int(value), 86400), 2**31 - 1)
-            value = datetime.fromtimestamp(int(value))
+            try:
+                return datetime.utcfromtimestamp(0) + timedelta(seconds=int(value))
+            except ValueError:
+                log.info('Failed to parse "%s" to datetime as timestamp, defaulting to None', value)
+                return None
+            except OverflowError:
+                log.info('Failed to parse "%s" to datetime as timestamp (out-of-bounds), defaulting to None', value)
+                return None
     return value
 
 
 def millisecondToHumanstr(milliseconds):
-    """ Returns human readable time duration from milliseconds.
-        HH:MM:SS:MMMM
+    """ Returns human readable time duration [D day[s], ]HH:MM:SS.UUU from milliseconds.
 
         Parameters:
-            milliseconds (str,int): time duration in milliseconds.
+            milliseconds (str, int): time duration in milliseconds.
     """
     milliseconds = int(milliseconds)
-    r = datetime.utcfromtimestamp(milliseconds / 1000)
-    f = r.strftime("%H:%M:%S.%f")
-    return f[:-2]
+    if milliseconds < 0:
+        return '-' + millisecondToHumanstr(abs(milliseconds))
+    secs, ms = divmod(milliseconds, 1000)
+    mins, secs = divmod(secs, 60)
+    hours, mins = divmod(mins, 60)
+    days, hours = divmod(hours, 24)
+    return ('' if days == 0 else f'{days} day{"s" if days > 1 else ""}, ') + f'{hours:02d}:{mins:02d}:{secs:02d}.{ms:03d}'
 
 
 def toList(value, itemcast=None, delim=','):
@@ -644,3 +651,8 @@ def openOrRead(file):
         return file.read()
     with open(file, 'rb') as f:
         return f.read()
+
+
+def sha1hash(guid):
+    """ Return the SHA1 hash of a guid. """
+    return sha1(guid.encode('utf-8')).hexdigest()
diff --git a/plexapi/video.py b/plexapi/video.py
index 486bb5c..4f5ab5e 100644
--- a/plexapi/video.py
+++ b/plexapi/video.py
@@ -1,5 +1,6 @@
 # -*- coding: utf-8 -*-
 import os
+from pathlib import Path
 from urllib.parse import quote_plus
 
 from plexapi import media, utils
@@ -445,6 +446,12 @@ class Movie(
         self._server.query(key, params=params, method=self._server._session.put)
         return self
 
+    @property
+    def metadataDirectory(self):
+        """ Returns the Plex Media Server data directory where the metadata is stored. """
+        guid_hash = utils.sha1hash(self.guid)
+        return str(Path('Metadata') / 'Movies' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
+
 
 @utils.registerPlexObject
 class Show(
@@ -655,6 +662,12 @@ class Show(
             filepaths += episode.download(_savepath, keep_original_name, **kwargs)
         return filepaths
 
+    @property
+    def metadataDirectory(self):
+        """ Returns the Plex Media Server data directory where the metadata is stored. """
+        guid_hash = utils.sha1hash(self.guid)
+        return str(Path('Metadata') / 'TV Shows' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
+
 
 @utils.registerPlexObject
 class Season(
@@ -808,6 +821,12 @@ class Season(
         """ Returns str, default title for a new syncItem. """
         return f'{self.parentTitle} - {self.title}'
 
+    @property
+    def metadataDirectory(self):
+        """ Returns the Plex Media Server data directory where the metadata is stored. """
+        guid_hash = utils.sha1hash(self.parentGuid)
+        return str(Path('Metadata') / 'TV Shows' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
+
 
 @utils.registerPlexObject
 class Episode(
@@ -1000,6 +1019,12 @@ class Episode(
         self._server.query(key, params=params, method=self._server._session.put)
         return self
 
+    @property
+    def metadataDirectory(self):
+        """ Returns the Plex Media Server data directory where the metadata is stored. """
+        guid_hash = utils.sha1hash(self.grandparentGuid)
+        return str(Path('Metadata') / 'TV Shows' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
+
 
 @utils.registerPlexObject
 class Clip(
@@ -1058,6 +1083,12 @@ class Clip(
         """ Returns a filename for use in download. """
         return self.title
 
+    @property
+    def metadataDirectory(self):
+        """ Returns the Plex Media Server data directory where the metadata is stored. """
+        guid_hash = utils.sha1hash(self.guid)
+        return str(Path('Metadata') / 'Movies' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
+
 
 class Extra(Clip):
     """ Represents a single Extra (trailer, behindTheScenes, etc). """


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 386d081..34f79b4 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -72,6 +72,8 @@ BASE_DIR_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 STUB_MOVIE_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "video_stub.mp4")
 STUB_MP3_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "audio_stub.mp3")
 STUB_IMAGE_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "cute_cat.jpg")
+# For the default Docker bootstrap test Plex Media Server data directory
+BOOTSTRAP_DATA_PATH = os.path.join(BASE_DIR_PATH, "plex", "db", "Library", "Application Support", "Plex Media Server")
 
 
 def pytest_addoption(parser):
diff --git a/tests/test_myplex.py b/tests/test_myplex.py
index 28e862a..58767ee 100644
--- a/tests/test_myplex.py
+++ b/tests/test_myplex.py
@@ -352,3 +352,7 @@ def test_myplex_pin(account, plex):
         account.removeManagedUserPin(homeuser)
     finally:
         account.removeHomeUser(homeuser)
+
+
+def test_myplex_geoip(account):
+    assert account.geoip(account.publicIP())
diff --git a/tests/test_server.py b/tests/test_server.py
index e1bcf9f..4c27781 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -566,3 +566,8 @@ def test_server_agents(plex):
     setting = next((s for s in settings if s.id == 'country'), None)
     assert setting
     assert setting.enumValues is not None
+
+
+def test_server_identity(plex):
+    identity = plex.identity()
+    assert identity.machineIdentifier == plex.machineIdentifier
diff --git a/tests/test_utils.py b/tests/test_utils.py
index fe16218..4d7ad4b 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -90,7 +90,13 @@ def test_utils_download(plex, episode):
 
 def test_millisecondToHumanstr():
     res = utils.millisecondToHumanstr(1000)
-    assert res == "00:00:01.0000"
+    assert res == "00:00:01.000"
+    res = utils.millisecondToHumanstr(-1000)
+    assert res == "-00:00:01.000"
+    res = utils.millisecondToHumanstr(123456789)
+    assert res == "1 day, 10:17:36.789"
+    res = utils.millisecondToHumanstr(-123456789)
+    assert res == "-1 day, 10:17:36.789"
 
 
 def test_toJson(movie):
diff --git a/tests/test_video.py b/tests/test_video.py
index fd9f962..cf717eb 100644
--- a/tests/test_video.py
+++ b/tests/test_video.py
@@ -405,7 +405,7 @@ def test_video_Movie_upload_select_remove_subtitle(movie, subtitle):
     subname = subtitle.name.rsplit(".", 1)[0]
     assert subname in subtitles
 
-    movie.subtitleStreams()[0].setDefault()
+    movie.subtitleStreams()[0].setSelected()
     movie.reload()
 
     subtitleSelection = movie.subtitleStreams()[0]
@@ -1459,3 +1459,15 @@ def test_video_optimize(plex, movie, tvshows, show):
         movie.optimize()
     with pytest.raises(BadRequest):
         movie.optimize(target="mobile", locationID=-100)
+
+
+def test_video_Movie_matadataDirectory(movie):
+    assert os.path.exists(os.path.join(utils.BOOTSTRAP_DATA_PATH, movie.metadataDirectory))
+
+    for poster in movie.posters():
+        if not poster.ratingKey.startswith('http'):
+            assert os.path.exists(os.path.join(utils.BOOTSTRAP_DATA_PATH, poster.resourceFilepath))
+
+    for art in movie.arts():
+        if not art.ratingKey.startswith('http'):
+            assert os.path.exists(os.path.join(utils.BOOTSTRAP_DATA_PATH, art.resourceFilepath))