Add Plex Connect Locally (#1313)
authorJeff Van Epps <jeff.vanepps@gmail.com>
Sun, 9 Jun 2024 12:33:55 +0000 (08:33 -0400)
committerGitHub <noreply@github.com>
Sun, 9 Jun 2024 12:33:55 +0000 (14:33 +0200)
music_assistant/server/models/music_provider.py
music_assistant/server/providers/plex/__init__.py
music_assistant/server/providers/plex/helpers.py

index ede8f9d7392b657674d97b4bc30cca4adf55dc14..09c3e395a3d377d4aa739c3099803d86b8445277 100644 (file)
@@ -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
                         )
index 0938cf2e449451656078f49811ab2e7d26f2f625..32f5fc8b3d3716189b818fddd19b19074008b60b 100644 (file)
@@ -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:
index 527773880318b6220219f961c837f08af635ef91..5afbe3e1c09f2e095ebc0d1d6456841c5a459e35 100644 (file)
@@ -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: