Skip to content

Commit

Permalink
Add Plex Connect Locally (#1313)
Browse files Browse the repository at this point in the history
  • Loading branch information
lordbah authored Jun 9, 2024
1 parent 1e85046 commit 4234c43
Show file tree
Hide file tree
Showing 3 changed files with 191 additions and 88 deletions.
3 changes: 2 additions & 1 deletion music_assistant/server/models/music_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,8 @@ async def sync_library(self, media_types: tuple[MediaType, ...]) -> None:
# 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
)
Expand Down
261 changes: 179 additions & 82 deletions music_assistant/server/providers/plex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,23 @@
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
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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):
Expand All @@ -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."""
Expand All @@ -250,11 +318,23 @@ def connect() -> PlexServer:
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
Expand Down Expand Up @@ -413,10 +493,18 @@ async def _parse_album(self, plex_album: PlexAlbum) -> Album:
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"):
Expand Down Expand Up @@ -455,7 +543,7 @@ async def _parse_artist(self, plex_artist: PlexArtist) -> Artist:
item_id=str(artist_id),
provider_domain=self.domain,
provider_instance=self.instance_id,
url=plex_artist.getWebURL(),
url=plex_artist.getWebURL(self._baseurl),
)
},
)
Expand Down Expand Up @@ -483,7 +571,7 @@ async def _parse_playlist(self, plex_playlist: PlexPlaylist) -> Playlist:
item_id=plex_playlist.key,
provider_domain=self.domain,
provider_instance=self.instance_id,
url=plex_playlist.getWebURL(),
url=plex_playlist.getWebURL(self._baseurl),
)
},
)
Expand Down Expand Up @@ -526,10 +614,17 @@ async def _parse_track(self, plex_track: PlexTrack) -> Track:
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.
Expand Down Expand Up @@ -794,6 +889,8 @@ def mark_played():

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:
Expand Down
Loading

0 comments on commit 4234c43

Please sign in to comment.