diff --git a/plexapi/audio.py b/plexapi/audio.py index e13827606..2a1698776 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 @@ def station(self): 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 @@ def _defaultSyncTitle(self): """ 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 @@ def _getWebURL(self, base=None): """ 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/collection.py b/plexapi/collection.py index d4820fe27..34940b9b0 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 @@ def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, clien 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/media.py b/plexapi/media.py index 0ea4e3458..c8412311e 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 @@ -1024,6 +1024,20 @@ def select(self): 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/photo.py b/plexapi/photo.py index 039ac80c5..7fcedcfed 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 @@ def _getWebURL(self, base=None): """ 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 @@ def _getWebURL(self, base=None): """ 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 c435613ae..4b5eac507 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 @@ def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, clien 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/utils.py b/plexapi/utils.py index f81211c36..558f0522a 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -13,6 +13,7 @@ from collections import deque from datetime import datetime, timedelta from getpass import getpass +from hashlib import sha1 from threading import Event, Thread from urllib.parse import quote @@ -650,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 486bb5caf..4f5ab5eb6 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 @@ def removeFromContinueWatching(self): 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 @@ def download(self, savepath=None, keep_original_name=False, subfolders=False, ** 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 @@ def _defaultSyncTitle(self): """ 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 @@ def removeFromContinueWatching(self): 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 @@ def _prettyfilename(self): """ 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). """ diff --git a/tests/conftest.py b/tests/conftest.py index 386d08175..34f79b487 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -72,6 +72,8 @@ 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_video.py b/tests/test_video.py index a760dd4fc..cf717eb5b 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -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))