From 4234c431af052803f54e2fa088f777849185df94 Mon Sep 17 00:00:00 2001 From: Jeff Van Epps Date: Sun, 9 Jun 2024 08:33:55 -0400 Subject: [PATCH] Add Plex Connect Locally (#1313) --- .../server/models/music_provider.py | 3 +- .../server/providers/plex/__init__.py | 261 ++++++++++++------ .../server/providers/plex/helpers.py | 15 +- 3 files changed, 191 insertions(+), 88 deletions(-) diff --git a/music_assistant/server/models/music_provider.py b/music_assistant/server/models/music_provider.py index ede8f9d7..09c3e395 100644 --- a/music_assistant/server/models/music_provider.py +++ b/music_assistant/server/models/music_provider.py @@ -410,7 +410,8 @@ class MusicProvider(Provider): # create full db item # note that we skip the metadata lookup purely to speed up the sync # the additional metadata is then lazy retrieved afterwards - prov_item.favorite = True + if self.is_streaming_provider: + prov_item.favorite = True library_item = await controller.add_item_to_library( prov_item, metadata_lookup=False ) diff --git a/music_assistant/server/providers/plex/__init__.py b/music_assistant/server/providers/plex/__init__.py index 0938cf2e..32f5fc8b 100644 --- a/music_assistant/server/providers/plex/__init__.py +++ b/music_assistant/server/providers/plex/__init__.py @@ -62,18 +62,23 @@ if TYPE_CHECKING: from music_assistant.server import MusicAssistant from music_assistant.server.models import ProviderInstanceType -CONF_ACTION_AUTH = "auth" +CONF_ACTION_AUTH_MYPLEX = "auth_myplex" +CONF_ACTION_AUTH_LOCAL = "auth_local" +CONF_ACTION_CLEAR_AUTH = "auth" CONF_ACTION_LIBRARY = "library" +CONF_ACTION_GDM = "gdm" + CONF_AUTH_TOKEN = "token" CONF_LIBRARY_ID = "library_id" CONF_LOCAL_SERVER_IP = "local_server_ip" CONF_LOCAL_SERVER_PORT = "local_server_port" CONF_LOCAL_SERVER_SSL = "local_server_ssl" CONF_LOCAL_SERVER_VERIFY_CERT = "local_server_verify_cert" -CONF_USE_GDM = "use_gdm" -CONF_ACTION_GDM = "gdm" + FAKE_ARTIST_PREFIX = "_fake://" +AUTH_TOKEN_UNAUTH = "local_auth" + async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig @@ -88,7 +93,7 @@ async def setup( return prov -async def get_config_entries( +async def get_config_entries( # noqa: PLR0915 mass: MusicAssistant, instance_id: str | None = None, # noqa: ARG001 action: str | None = None, @@ -101,30 +106,31 @@ async def get_config_entries( action: [optional] action key called from config entries UI. values: the (intermediate) raw values for config entries sent with the action. """ - conf_gdm = ConfigEntry( - key=CONF_USE_GDM, - type=ConfigEntryType.BOOLEAN, - label="GDM", - default_value=False, - description='Enable "GDM" to discover local Plex servers automatically.', - action=CONF_ACTION_GDM, - action_label="Use Plex GDM to discover local servers", - ) - if action == CONF_ACTION_GDM and (server_details := await discover_local_servers()): - if server_details[0] is None and server_details[1] is None: - values[CONF_LOCAL_SERVER_IP] = "Discovery failed, please add IP manually" - values[CONF_LOCAL_SERVER_PORT] = "Discovery failed, please add Port manually" - values[CONF_LOCAL_SERVER_SSL] = "Discovery failed, please set SSL manually" - values[CONF_LOCAL_SERVER_VERIFY_CERT] = ( - "Discovery failed, please set " "Verify certificate manually" - ) - else: + # handle action GDM discovery + if action == CONF_ACTION_GDM: + server_details = await discover_local_servers() + if server_details and server_details[0] and server_details[1]: values[CONF_LOCAL_SERVER_IP] = server_details[0] values[CONF_LOCAL_SERVER_PORT] = server_details[1] values[CONF_LOCAL_SERVER_SSL] = False values[CONF_LOCAL_SERVER_VERIFY_CERT] = False - # config flow auth action/step (authenticate button clicked) - if action == CONF_ACTION_AUTH: + else: + values[CONF_LOCAL_SERVER_IP] = "Discovery failed, please add IP manually" + values[CONF_LOCAL_SERVER_PORT] = 32400 + values[CONF_LOCAL_SERVER_SSL] = False + values[CONF_LOCAL_SERVER_VERIFY_CERT] = True + + # handle action clear authentication + if action == CONF_ACTION_CLEAR_AUTH: + values[CONF_AUTH_TOKEN] = None + values[CONF_LOCAL_SERVER_IP] = None + values[CONF_LOCAL_SERVER_PORT] = 32400 + values[CONF_LOCAL_SERVER_SSL] = False + values[CONF_LOCAL_SERVER_VERIFY_CERT] = True + + # handle action MyPlex auth + if action == CONF_ACTION_AUTH_MYPLEX: + values[CONF_AUTH_TOKEN] = None async with AuthenticationHelper(mass, values["session_id"]) as auth_helper: plex_auth = MyPlexPinLogin(headers={"X-Plex-Product": "Music Assistant"}, oauth=True) auth_url = plex_auth.oauthUrl(auth_helper.callback_url) @@ -135,52 +141,28 @@ async def get_config_entries( # set the retrieved token on the values object to pass along values[CONF_AUTH_TOKEN] = plex_auth.token - # config flow auth action/step to pick the library to use - # because this call is very slow, we only show/calculate the dropdown if we do - # not yet have this info or we/user invalidated it. - conf_libraries = ConfigEntry( - key=CONF_LIBRARY_ID, - type=ConfigEntryType.STRING, - label="Library", - required=True, - description="The library to connect to (e.g. Music)", - depends_on=CONF_AUTH_TOKEN, - action=CONF_ACTION_LIBRARY, - action_label="Select Plex Music Library", - ) - if action in (CONF_ACTION_LIBRARY, CONF_ACTION_AUTH): - token = mass.config.decrypt_string(values.get(CONF_AUTH_TOKEN)) - server_http_ip = values.get(CONF_LOCAL_SERVER_IP) - server_http_port = values.get(CONF_LOCAL_SERVER_PORT) - server_http_ssl = values.get(CONF_LOCAL_SERVER_SSL) - server_http_verify_cert = values.get(CONF_LOCAL_SERVER_VERIFY_CERT) - if not ( - libraries := await get_libraries( - mass, - token, - server_http_ssl, - server_http_ip, - server_http_port, - server_http_verify_cert, - ) - ): - msg = "Unable to retrieve Servers and/or Music Libraries" - raise LoginFailed(msg) - conf_libraries.options = tuple( - # use the same value for both the value and the title - # until we find out what plex uses as stable identifiers - ConfigValueOption( - title=x, - value=x, + # handle action Local auth (no MyPlex) + if action == CONF_ACTION_AUTH_LOCAL: + values[CONF_AUTH_TOKEN] = AUTH_TOKEN_UNAUTH + + # collect all config entries to show + entries: list[ConfigEntry] = [] + + # show GDM discovery (if we do not yet have any server details) + if values is None or not values.get(CONF_LOCAL_SERVER_IP): + entries.append( + ConfigEntry( + key=CONF_ACTION_GDM, + type=ConfigEntryType.ACTION, + label="Use Plex GDM to discover local servers", + description='Enable "GDM" to discover local Plex servers automatically.', + action=CONF_ACTION_GDM, + action_label="Use Plex GDM to discover local servers", ) - for x in libraries ) - # select first library as (default) value - conf_libraries.default_value = libraries[0] - conf_libraries.value = libraries[0] - # return the collected config entries - return ( - conf_gdm, + + # server details config entries (IP, port etc.) + entries += [ ConfigEntry( key=CONF_LOCAL_SERVER_IP, type=ConfigEntryType.STRING, @@ -195,6 +177,7 @@ async def get_config_entries( label="Local server port", description="The local server port (e.g. 32400)", required=True, + default_value=32400, value=values.get(CONF_LOCAL_SERVER_PORT) if values else None, ), ConfigEntry( @@ -212,18 +195,102 @@ async def get_config_entries( description="Verify local server SSL certificate", required=True, default_value=True, + depends_on=CONF_LOCAL_SERVER_SSL, + category="advanced", ), ConfigEntry( key=CONF_AUTH_TOKEN, type=ConfigEntryType.SECURE_STRING, - label="Authentication token for MyPlex.tv", - description="You need to link Music Assistant to your MyPlex account.", - action=CONF_ACTION_AUTH, - action_label="Authenticate on MyPlex.tv", + label=CONF_AUTH_TOKEN, + action=CONF_AUTH_TOKEN, value=values.get(CONF_AUTH_TOKEN) if values else None, + hidden=True, ), - conf_libraries, - ) + ] + + # config flow auth action/step to pick the library to use + # because this call is very slow, we only show/calculate the dropdown if we do + # not yet have this info or we/user invalidated it. + if values and values.get(CONF_AUTH_TOKEN): + conf_libraries = ConfigEntry( + key=CONF_LIBRARY_ID, + type=ConfigEntryType.STRING, + label="Library", + required=True, + description="The library to connect to (e.g. Music)", + depends_on=CONF_AUTH_TOKEN, + action=CONF_ACTION_LIBRARY, + action_label="Select Plex Music Library", + ) + if action in (CONF_ACTION_LIBRARY, CONF_ACTION_AUTH_MYPLEX, CONF_ACTION_AUTH_LOCAL): + token = mass.config.decrypt_string(values.get(CONF_AUTH_TOKEN)) + server_http_ip = values.get(CONF_LOCAL_SERVER_IP) + server_http_port = values.get(CONF_LOCAL_SERVER_PORT) + server_http_ssl = values.get(CONF_LOCAL_SERVER_SSL) + server_http_verify_cert = values.get(CONF_LOCAL_SERVER_VERIFY_CERT) + if not ( + libraries := await get_libraries( + mass, + token, + server_http_ssl, + server_http_ip, + server_http_port, + server_http_verify_cert, + ) + ): + msg = "Unable to retrieve Servers and/or Music Libraries" + raise LoginFailed(msg) + conf_libraries.options = tuple( + # use the same value for both the value and the title + # until we find out what plex uses as stable identifiers + ConfigValueOption( + title=x, + value=x, + ) + for x in libraries + ) + # select first library as (default) value + conf_libraries.default_value = libraries[0] + conf_libraries.value = libraries[0] + entries.append(conf_libraries) + + # show authentication options + if values is None or not values.get(CONF_AUTH_TOKEN): + entries.append( + ConfigEntry( + key=CONF_ACTION_AUTH_MYPLEX, + type=ConfigEntryType.ACTION, + label="Authenticate with MyPlex", + description="Authenticate with MyPlex to access your library.", + action=CONF_ACTION_AUTH_MYPLEX, + action_label="Authenticate with MyPlex", + ) + ) + entries.append( + ConfigEntry( + key=CONF_ACTION_AUTH_LOCAL, + type=ConfigEntryType.ACTION, + label="Authenticate locally", + description="Authenticate locally to access your library.", + action=CONF_ACTION_AUTH_LOCAL, + action_label="Authenticate locally", + ) + ) + else: + entries.append( + ConfigEntry( + key=CONF_ACTION_CLEAR_AUTH, + type=ConfigEntryType.ACTION, + label="Clear authentication", + description="Clear the current authentication details.", + action=CONF_ACTION_CLEAR_AUTH, + action_label="Clear authentication", + required=False, + ) + ) + + # return all config entries + return tuple(entries) class PlexProvider(MusicProvider): @@ -232,6 +299,7 @@ class PlexProvider(MusicProvider): _plex_server: PlexServer = None _plex_library: PlexMusicSection = None _myplex_account: MyPlexAccount = None + _baseurl: str = None async def handle_async_init(self) -> None: """Set up the music provider by connecting to the server.""" @@ -250,11 +318,23 @@ class PlexProvider(MusicProvider): local_server_protocol = ( "https" if self.config.get_value(CONF_LOCAL_SERVER_SSL) else "http" ) - plex_server = PlexServer( - f"{local_server_protocol}://{self.config.get_value(CONF_LOCAL_SERVER_IP)}:{self.config.get_value(CONF_LOCAL_SERVER_PORT)}", - token=self.config.get_value(CONF_AUTH_TOKEN), - session=session, + token = self.config.get_value(CONF_AUTH_TOKEN) + plex_url = ( + f"{local_server_protocol}://{self.config.get_value(CONF_LOCAL_SERVER_IP)}" + f":{self.config.get_value(CONF_LOCAL_SERVER_PORT)}" ) + if token == AUTH_TOKEN_UNAUTH: + # Doing local connection, not via plex.tv. + plex_server = PlexServer(plex_url) + # I don't think PlexAPI intends for this to be accessible, but we need it. + self._baseurl = plex_server._baseurl + else: + plex_server = PlexServer( + plex_url, + token, + session=session, + ) + except plexapi.exceptions.BadRequest as err: if "Invalid token" in str(err): # token invalid, invalidate the config @@ -413,10 +493,18 @@ class PlexProvider(MusicProvider): item_id=str(album_id), provider_domain=self.domain, provider_instance=self.instance_id, - url=plex_album.getWebURL(), + url=plex_album.getWebURL(self._baseurl), ) }, ) + # Only add 5-star rated albums to Favorites. rating will be 10.0 for those. + # TODO: Let user set threshold? + try: + album.favorite = plex_album._data.attrib["userRating"] == "10.0" + except KeyError: + # Log but suppress exception, allow sync to continue + self.logger.error("ERROR: %s has no rating", plex_album.title) + if plex_album.year: album.year = plex_album.year if thumb := plex_album.firstAttr("thumb", "parentThumb", "grandparentThumb"): @@ -455,7 +543,7 @@ class PlexProvider(MusicProvider): item_id=str(artist_id), provider_domain=self.domain, provider_instance=self.instance_id, - url=plex_artist.getWebURL(), + url=plex_artist.getWebURL(self._baseurl), ) }, ) @@ -483,7 +571,7 @@ class PlexProvider(MusicProvider): item_id=plex_playlist.key, provider_domain=self.domain, provider_instance=self.instance_id, - url=plex_playlist.getWebURL(), + url=plex_playlist.getWebURL(self._baseurl), ) }, ) @@ -526,10 +614,17 @@ class PlexProvider(MusicProvider): ContentType.try_parse(content) if content else ContentType.UNKNOWN ), ), - url=plex_track.getWebURL(), + url=plex_track.getWebURL(self._baseurl), ) }, ) + # Only add 5-star rated tracks to Favorites. userRating will be 10.0 for those. + # TODO: Let user set threshold? + try: + track.favorite = plex_track._data.attrib["userRating"] == "10.0" + except KeyError: + # Log but suppress exception, allow sync to continue + self.logger.error("ERROR: %s has no userRating", plex_track.title) if plex_track.originalTitle and plex_track.originalTitle != plex_track.grandparentTitle: # The artist of the track if different from the album's artist. @@ -794,6 +889,8 @@ class PlexProvider(MusicProvider): async def get_myplex_account_and_refresh_token(self, auth_token: str) -> MyPlexAccount: """Get a MyPlexAccount object and refresh the token if needed.""" + if auth_token == AUTH_TOKEN_UNAUTH: + return self._myplex_account def _refresh_plex_token(): if self._myplex_account is None: diff --git a/music_assistant/server/providers/plex/helpers.py b/music_assistant/server/providers/plex/helpers.py index 52777388..5afbe3e1 100644 --- a/music_assistant/server/providers/plex/helpers.py +++ b/music_assistant/server/providers/plex/helpers.py @@ -36,11 +36,16 @@ async def get_libraries( session = requests.Session() session.verify = local_server_verify_cert local_server_protocol = "https" if local_server_ssl else "http" - plex_server: PlexServer = PlexServer( - f"{local_server_protocol}://{local_server_ip}:{local_server_port}", - auth_token, - session=session, - ) + if auth_token is None: + plex_server: PlexServer = PlexServer( + f"{local_server_protocol}://{local_server_ip}:{local_server_port}" + ) + else: + plex_server: PlexServer = PlexServer( + f"{local_server_protocol}://{local_server_ip}:{local_server_port}", + auth_token, + session=session, + ) for media_section in plex_server.library.sections(): media_section: PlexLibrarySection if media_section.type != PlexMusicSection.TYPE: -- 2.34.1