# -*- coding: utf-8 -*- from __future__ import annotations import os from pathlib import Path from urllib.parse import quote_plus from typing import Any, Dict, List, Optional, TypeVar from plexapi import media, utils from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession from plexapi.exceptions import BadRequest from plexapi.mixins import ( AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin, ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin, ArtistEditMixins, AlbumEditMixins, TrackEditMixins ) from plexapi.playlist import Playlist TAudio = TypeVar("TAudio", bound="Audio") TTrack = TypeVar("TTrack", bound="Track") class Audio(PlexPartialObject, PlayedUnplayedMixin): """ Base class for all audio objects including :class:`~plexapi.audio.Artist`, :class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`. Attributes: addedAt (datetime): Datetime the item was added to the library. art (str): URL to artwork image (/library/metadata//art/). artBlurHash (str): BlurHash string for artwork image. distance (float): Sonic Distance of the item from the seed item. fields (List<:class:`~plexapi.media.Field`>): List of field objects. guid (str): Plex GUID for the artist, album, or track (plex://artist/5d07bcb0403c64029053ac4c). index (int): Plex index number (often the track number). key (str): API URL (/library/metadata/). lastRatedAt (datetime): Datetime the item was last rated. lastViewedAt (datetime): Datetime the item was last played. librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. listType (str): Hardcoded as 'audio' (useful for search filters). moods (List<:class:`~plexapi.media.Mood`>): List of mood objects. musicAnalysisVersion (int): The Plex music analysis version for the item. ratingKey (int): Unique key identifying the item. summary (str): Summary of the artist, album, or track. thumb (str): URL to thumbnail image (/library/metadata//thumb/). thumbBlurHash (str): BlurHash string for thumbnail image. title (str): Name of the artist, album, or track (Jason Mraz, We Sing, Lucky, etc.). titleSort (str): Title to use when sorting (defaults to title). type (str): 'artist', 'album', or 'track'. updatedAt (datetime): Datetime the item was updated. userRating (float): Rating of the item (0.0 - 10.0) equaling (0 stars - 5 stars). viewCount (int): Count of times the item was played. """ METADATA_TYPE = 'track' def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.art = data.attrib.get('art') self.artBlurHash = data.attrib.get('artBlurHash') self.distance = utils.cast(float, data.attrib.get('distance')) self.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') self.index = utils.cast(int, data.attrib.get('index')) self.key = data.attrib.get('key', '') self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionTitle = data.attrib.get('librarySectionTitle') self.listType = 'audio' self.moods = self.findItems(data, media.Mood) self.musicAnalysisVersion = utils.cast(int, data.attrib.get('musicAnalysisVersion')) self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.summary = data.attrib.get('summary') self.thumb = data.attrib.get('thumb') self.thumbBlurHash = data.attrib.get('thumbBlurHash') self.title = data.attrib.get('title') self.titleSort = data.attrib.get('titleSort', self.title) self.type = data.attrib.get('type') self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.userRating = utils.cast(float, data.attrib.get('userRating')) self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0)) def url(self, part): """ Returns the full URL for the audio item. Typically used for getting a specific track. """ return self._server.url(part, includeToken=True) if part else None def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ return self.title @property def hasSonicAnalysis(self): """ Returns True if the audio has been sonically analyzed. """ return self.musicAnalysisVersion == 1 def sync(self, bitrate, client=None, clientId=None, limit=None, title=None): """ Add current audio (artist, album or track) as sync item for specified device. See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions. Parameters: bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the module :mod:`~plexapi.sync`. client (:class:`~plexapi.myplex.MyPlexDevice`): sync destination, see :func:`~plexapi.myplex.MyPlexAccount.sync`. clientId (str): sync destination, see :func:`~plexapi.myplex.MyPlexAccount.sync`. limit (int): maximum count of items to sync, unlimited if `None`. title (str): descriptive title for the new :class:`~plexapi.sync.SyncItem`, if empty the value would be generated from metadata of current media. Returns: :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. """ from plexapi.sync import SyncItem, Policy, MediaSettings myplex = self._server.myPlexAccount() sync_item = SyncItem(self._server, None) sync_item.title = title if title else self._defaultSyncTitle() sync_item.rootTitle = self.title sync_item.contentType = self.listType sync_item.metadataType = self.METADATA_TYPE sync_item.machineIdentifier = self._server.machineIdentifier section = self._server.library.sectionByID(self.librarySectionID) sync_item.location = f'library://{section.uuid}/item/{quote_plus(self.key)}' sync_item.policy = Policy.create(limit) sync_item.mediaSettings = MediaSettings.createMusic(bitrate) return myplex.sync(sync_item, client=client, clientId=clientId) def sonicallySimilar( self: TAudio, limit: Optional[int] = None, maxDistance: Optional[float] = None, **kwargs, ) -> List[TAudio]: """Returns a list of sonically similar audio items. Parameters: limit (int): Maximum count of items to return. Default 50 (server default) maxDistance (float): Maximum distance between tracks, 0.0 - 1.0. Default 0.25 (server default). **kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.fetchItems`. Returns: List[:class:`~plexapi.audio.Audio`]: list of sonically similar audio items. """ key = f"{self.key}/nearest" params: Dict[str, Any] = {} if limit is not None: params['limit'] = limit if maxDistance is not None: params['maxDistance'] = maxDistance key += utils.joinArgs(params) return self.fetchItems( key, cls=type(self), **kwargs, ) @utils.registerPlexObject class Artist( Audio, AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, ArtMixin, PosterMixin, ThemeMixin, ArtistEditMixins ): """ Represents a single Artist. Attributes: TAG (str): 'Directory' TYPE (str): 'artist' albumSort (int): Setting that indicates how albums are sorted for the artist (-1 = Library default, 0 = Newest first, 1 = Oldest first, 2 = By name). collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. countries (List<:class:`~plexapi.media.Country`>): List country objects. genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. key (str): API URL (/library/metadata/). labels (List<:class:`~plexapi.media.Label`>): List of label objects. locations (List): List of folder paths where the artist is found on disk. similar (List<:class:`~plexapi.media.Similar`>): List of similar objects. styles (List<:class:`~plexapi.media.Style`>): List of style objects. theme (str): URL to theme resource (/library/metadata//theme/). """ TAG = 'Directory' TYPE = 'artist' def _loadData(self, data): """ Load attribute values from Plex XML response. """ Audio._loadData(self, data) self.albumSort = utils.cast(int, data.attrib.get('albumSort', '-1')) self.collections = self.findItems(data, media.Collection) self.countries = self.findItems(data, media.Country) self.genres = self.findItems(data, media.Genre) self.guids = self.findItems(data, media.Guid) self.key = self.key.replace('/children', '') # FIX_BUG_50 self.labels = self.findItems(data, media.Label) self.locations = self.listAttrs(data, 'path', etag='Location') self.similar = self.findItems(data, media.Similar) self.styles = self.findItems(data, media.Style) self.theme = data.attrib.get('theme') def __iter__(self): for album in self.albums(): yield album def album(self, title): """ Returns the :class:`~plexapi.audio.Album` that matches the specified title. Parameters: title (str): Title of the album to return. """ return self.section().get( title=title, libtype='album', filters={'artist.id': self.ratingKey} ) def albums(self, **kwargs): """ Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """ return self.section().search( libtype='album', filters={**kwargs.pop('filters', {}), 'artist.id': self.ratingKey}, **kwargs ) def track(self, title=None, album=None, track=None): """ Returns the :class:`~plexapi.audio.Track` that matches the specified title. Parameters: title (str): Title of the track to return. album (str): Album name (default: None; required if title not specified). track (int): Track number (default: None; required if title not specified). Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or album and track parameters are missing. """ key = f'{self.key}/allLeaves' if title is not None: return self.fetchItem(key, Track, title__iexact=title) elif album is not None and track is not None: return self.fetchItem(key, Track, parentTitle__iexact=album, index=track) raise BadRequest('Missing argument: title or album and track are required') def tracks(self, **kwargs): """ Returns a list of :class:`~plexapi.audio.Track` objects by the artist. """ key = f'{self.key}/allLeaves' return self.fetchItems(key, Track, **kwargs) def get(self, title=None, album=None, track=None): """ Alias of :func:`~plexapi.audio.Artist.track`. """ return self.track(title, album, track) def download(self, savepath=None, keep_original_name=False, subfolders=False, **kwargs): """ Download all tracks from the artist. See :func:`~plexapi.base.Playable.download` for details. Parameters: savepath (str): Defaults to current working dir. keep_original_name (bool): True to keep the original filename otherwise a friendlier filename is generated. subfolders (bool): True to separate tracks in to album folders. **kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`. """ filepaths = [] for track in self.tracks(): _savepath = os.path.join(savepath, track.parentTitle) if subfolders else savepath filepaths += track.download(_savepath, keep_original_name, **kwargs) return filepaths def station(self): """ Returns a :class:`~plexapi.playlist.Playlist` artist radio station or `None`. """ 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( Audio, SplitMergeMixin, UnmatchMatchMixin, RatingMixin, ArtMixin, PosterMixin, ThemeUrlMixin, AlbumEditMixins ): """ Represents a single Album. Attributes: TAG (str): 'Directory' TYPE (str): 'album' collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. formats (List<:class:`~plexapi.media.Format`>): List of format objects. genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. key (str): API URL (/library/metadata/). labels (List<:class:`~plexapi.media.Label`>): List of label objects. leafCount (int): Number of items in the album view. loudnessAnalysisVersion (int): The Plex loudness analysis version level. originallyAvailableAt (datetime): Datetime the album was released. parentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c). parentKey (str): API URL of the album artist (/library/metadata/). parentRatingKey (int): Unique key identifying the album artist. parentTheme (str): URL to artist theme resource (/library/metadata//theme/). parentThumb (str): URL to album artist thumbnail image (/library/metadata//thumb/). parentTitle (str): Name of the album artist. rating (float): Album rating (7.9; 9.8; 8.1). studio (str): Studio that released the album. styles (List<:class:`~plexapi.media.Style`>): List of style objects. subformats (List<:class:`~plexapi.media.Subformat`>): List of subformat objects. viewedLeafCount (int): Number of items marked as played in the album view. year (int): Year the album was released. """ TAG = 'Directory' TYPE = 'album' def _loadData(self, data): """ Load attribute values from Plex XML response. """ Audio._loadData(self, data) self.collections = self.findItems(data, media.Collection) self.formats = self.findItems(data, media.Format) self.genres = self.findItems(data, media.Genre) self.guids = self.findItems(data, media.Guid) self.key = self.key.replace('/children', '') # FIX_BUG_50 self.labels = self.findItems(data, media.Label) self.leafCount = utils.cast(int, data.attrib.get('leafCount')) self.loudnessAnalysisVersion = utils.cast(int, data.attrib.get('loudnessAnalysisVersion')) self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.parentGuid = data.attrib.get('parentGuid') self.parentKey = data.attrib.get('parentKey') self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) self.parentTheme = data.attrib.get('parentTheme') self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') self.rating = utils.cast(float, data.attrib.get('rating')) self.studio = data.attrib.get('studio') self.styles = self.findItems(data, media.Style) self.subformats = self.findItems(data, media.Subformat) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.year = utils.cast(int, data.attrib.get('year')) def __iter__(self): for track in self.tracks(): yield track def track(self, title=None, track=None): """ Returns the :class:`~plexapi.audio.Track` that matches the specified title. Parameters: title (str): Title of the track to return. track (int): Track number (default: None; required if title not specified). Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or track parameter is missing. """ key = f'{self.key}/children' if title is not None and not isinstance(title, int): return self.fetchItem(key, Track, title__iexact=title) elif track is not None or isinstance(title, int): if isinstance(title, int): index = title else: index = track return self.fetchItem(key, Track, parentTitle__iexact=self.title, index=index) raise BadRequest('Missing argument: title or track is required') def tracks(self, **kwargs): """ Returns a list of :class:`~plexapi.audio.Track` objects in the album. """ key = f'{self.key}/children' return self.fetchItems(key, Track, **kwargs) def get(self, title=None, track=None): """ Alias of :func:`~plexapi.audio.Album.track`. """ return self.track(title, track) def artist(self): """ Return the album's :class:`~plexapi.audio.Artist`. """ return self.fetchItem(self.parentKey) def download(self, savepath=None, keep_original_name=False, **kwargs): """ Download all tracks from the album. See :func:`~plexapi.base.Playable.download` for details. Parameters: savepath (str): Defaults to current working dir. keep_original_name (bool): True to keep the original filename otherwise a friendlier filename is generated. **kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`. """ filepaths = [] for track in self.tracks(): filepaths += track.download(savepath, keep_original_name, **kwargs) return filepaths 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( Audio, Playable, ExtrasMixin, RatingMixin, ArtUrlMixin, PosterUrlMixin, ThemeUrlMixin, TrackEditMixins ): """ Represents a single Track. Attributes: TAG (str): 'Directory' TYPE (str): 'track' chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects. chapterSource (str): Unknown collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. duration (int): Length of the track in milliseconds. genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. grandparentArt (str): URL to album artist artwork (/library/metadata//art/). grandparentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c). grandparentKey (str): API URL of the album artist (/library/metadata/). grandparentRatingKey (int): Unique key identifying the album artist. grandparentTheme (str): URL to artist theme resource (/library/metadata//theme/). (/library/metadata//theme/). grandparentThumb (str): URL to album artist thumbnail image (/library/metadata//thumb/). grandparentTitle (str): Name of the album artist for the track. guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. labels (List<:class:`~plexapi.media.Label`>): List of label objects. media (List<:class:`~plexapi.media.Media`>): List of media objects. originalTitle (str): The artist for the track. parentGuid (str): Plex GUID for the album (plex://album/5d07cd8e403c640290f180f9). parentIndex (int): Disc number of the track. parentKey (str): API URL of the album (/library/metadata/). parentRatingKey (int): Unique key identifying the album. parentThumb (str): URL to album thumbnail image (/library/metadata//thumb/). parentTitle (str): Name of the album for the track. primaryExtraKey (str) API URL for the primary extra for the track. ratingCount (int): Number of listeners who have scrobbled this track, as reported by Last.fm. skipCount (int): Number of times the track has been skipped. sourceURI (str): Remote server URI (server:///com.plexapp.plugins.library) (remote playlist item only). viewOffset (int): View offset in milliseconds. year (int): Year the track was released. """ TAG = 'Track' TYPE = 'track' def _loadData(self, data): """ Load attribute values from Plex XML response. """ Audio._loadData(self, data) Playable._loadData(self, data) self.chapters = self.findItems(data, media.Chapter) self.chapterSource = data.attrib.get('chapterSource') self.collections = self.findItems(data, media.Collection) self.duration = utils.cast(int, data.attrib.get('duration')) self.genres = self.findItems(data, media.Genre) self.grandparentArt = data.attrib.get('grandparentArt') self.grandparentGuid = data.attrib.get('grandparentGuid') self.grandparentKey = data.attrib.get('grandparentKey') self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey')) self.grandparentTheme = data.attrib.get('grandparentTheme') self.grandparentThumb = data.attrib.get('grandparentThumb') self.grandparentTitle = data.attrib.get('grandparentTitle') self.guids = self.findItems(data, media.Guid) self.labels = self.findItems(data, media.Label) self.media = self.findItems(data, media.Media) self.originalTitle = data.attrib.get('originalTitle') self.parentGuid = data.attrib.get('parentGuid') self.parentIndex = utils.cast(int, data.attrib.get('parentIndex')) self.parentKey = data.attrib.get('parentKey') self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') self.primaryExtraKey = data.attrib.get('primaryExtraKey') self.ratingCount = utils.cast(int, data.attrib.get('ratingCount')) self.skipCount = utils.cast(int, data.attrib.get('skipCount')) self.sourceURI = data.attrib.get('source') # remote playlist item self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.year = utils.cast(int, data.attrib.get('year')) @property def locations(self): """ This does not exist in plex xml response but is added to have a common interface to get the locations of the track. Returns: List of file paths where the track is found on disk. """ return [part.file for part in self.iterParts() if part] @property def trackNumber(self): """ Returns the track number. """ return self.index def _prettyfilename(self): """ Returns a filename for use in download. """ return f'{self.grandparentTitle} - {self.parentTitle} - {str(self.trackNumber).zfill(2)} - {self.title}' def album(self): """ Return the track's :class:`~plexapi.audio.Album`. """ return self.fetchItem(self.parentKey) def artist(self): """ Return the track's :class:`~plexapi.audio.Artist`. """ return self.fetchItem(self.grandparentKey) def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ return f'{self.grandparentTitle} - {self.parentTitle} - {self.title}' 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') def sonicAdventure( self: TTrack, to: TTrack, **kwargs: Any, ) -> list[TTrack]: """Returns a sonic adventure from the current track to the specified track. Parameters: to (:class:`~plexapi.audio.Track`): The target track for the sonic adventure. **kwargs: Additional options passed into :func:`~plexapi.library.MusicSection.sonicAdventure`. Returns: List[:class:`~plexapi.audio.Track`]: list of tracks in the sonic adventure. """ return self.section().sonicAdventure(self, to, **kwargs) @utils.registerPlexObject class TrackSession(PlexSession, Track): """ Represents a single Track session loaded from :func:`~plexapi.server.PlexServer.sessions`. """ _SESSIONTYPE = True def _loadData(self, data): """ Load attribute values from Plex XML response. """ Track._loadData(self, data) PlexSession._loadData(self, data) @utils.registerPlexObject class TrackHistory(PlexHistory, Track): """ Represents a single Track history entry loaded from :func:`~plexapi.server.PlexServer.history`. """ _HISTORYTYPE = True def _loadData(self, data): """ Load attribute values from Plex XML response. """ Track._loadData(self, data) PlexHistory._loadData(self, data)