Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Plex Connect Locally #1313

Merged
merged 19 commits into from
Jun 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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