From 6b89f473a95072fb669e09fe7e7fe7838c19df72 Mon Sep 17 00:00:00 2001 From: Matthieu B Date: Mon, 14 Jul 2025 14:28:36 +0200 Subject: [PATCH] fix media server --- app/services/media/audiobookshelf.py | 139 ++++++--------- app/services/media/jellyfin.py | 165 ++++------------- app/services/media/plex.py | 166 ++---------------- .../components/passkey_management.html | 2 +- 4 files changed, 98 insertions(+), 374 deletions(-) diff --git a/app/services/media/audiobookshelf.py b/app/services/media/audiobookshelf.py index a5deea72c..8d0fbb6f5 100644 --- a/app/services/media/audiobookshelf.py +++ b/app/services/media/audiobookshelf.py @@ -101,69 +101,51 @@ class AudiobookshelfClient(RestApiMixin): # --- users --------------------------------------------------------- def list_users(self) -> List[User]: - """Read users from Audiobookshelf and reflect them locally. + """Read users from Audiobookshelf and reflect them locally.""" + server_id = getattr(self, "server_id", None) + if server_id is None: + return [] - At the moment we *only* sync users that already exist in the - local DB. Creating new users and managing their permissions is - still on the TODO list. - """ try: data = self.get(f"{self.API_PREFIX}/users").json() - raw_users: List[Dict[str, Any]] = data.get("users", data) # ABS may wrap list in {"users": [...]} + raw_users: List[Dict[str, Any]] = data.get("users", data) except Exception as exc: logging.warning("ABS: failed to list users – %s", exc) return [] - # Index by ABS user‐id for quick lookups raw_by_id = {u["id"]: u for u in raw_users} - # ------------------------------------------------------------------ - # 1) Add new users or update basic fields so the UI has fresh data - # ------------------------------------------------------------------ - for uid, remote in raw_by_id.items(): - db_row = ( - User.query - .filter_by(token=uid, server_id=getattr(self, "server_id", None)) - .first() - ) - if not db_row: - db_row = User( - token=uid, - username=remote.get("username", "abs-user"), - email=remote.get("email", ""), - code="empty", # placeholder – signifies "no invite code" - password="abs", # placeholder - server_id=getattr(self, "server_id", None), - ) - db.session.add(db_row) - else: - # Simple field refresh (username/email might have changed) - db_row.username = remote.get("username", db_row.username) - db_row.email = remote.get("email", db_row.email) + try: + # Add new users or update existing ones + for uid, remote in raw_by_id.items(): + db_row = User.query.filter_by(token=uid, server_id=server_id).first() + if not db_row: + db_row = User( + token=uid, + username=remote.get("username", "abs-user"), + email=remote.get("email", ""), + code="empty", + server_id=server_id, + ) + db.session.add(db_row) + else: + db_row.username = remote.get("username", db_row.username) + db_row.email = remote.get("email", db_row.email) - db.session.commit() + # Remove users that no longer exist upstream + to_check = User.query.filter(User.server_id == server_id).all() + for local in to_check: + if local.token not in raw_by_id: + db.session.delete(local) - # ------------------------------------------------------------------ - # 2) Remove local users that have disappeared upstream. This keeps - # the Wizarr DB in sync so "ghost" accounts don't show up as - # "Local" after they were deleted on the ABS server. - # ------------------------------------------------------------------ - to_check = ( - User.query - .filter(User.server_id == getattr(self, "server_id", None)) - .all() - ) - for local in to_check: - if local.token not in raw_by_id: - db.session.delete(local) + db.session.commit() - db.session.commit() + except Exception as exc: + logging.error("ABS: failed to sync users – %s", exc) + db.session.rollback() + return [] - return ( - User.query - .filter(User.server_id == getattr(self, "server_id", None)) - .all() - ) + return User.query.filter(User.server_id == server_id).all() # --- user management ------------------------------------------------ @@ -215,7 +197,7 @@ class AudiobookshelfClient(RestApiMixin): def _mark_invite_used(inv, user): inv.used_by = user from app.services.invites import mark_server_used - mark_server_used(inv, getattr(user, "server_id", None)) + mark_server_used(inv, user.server_id) def _set_specific_libraries(self, user_id: str, library_ids: List[str]): """Restrict the given *user* to the supplied library IDs. @@ -261,9 +243,13 @@ class AudiobookshelfClient(RestApiMixin): if not ok: return False, msg + server_id = getattr(self, "server_id", None) + if server_id is None: + return False, "Server configuration error" + existing = User.query.filter( or_(User.username == username, User.email == email), - User.server_id == getattr(self, "server_id", None) + User.server_id == server_id ).first() if existing: return False, "User or e-mail already exists." @@ -271,67 +257,48 @@ class AudiobookshelfClient(RestApiMixin): try: user_id = self.create_user(username, password, email=email) if not user_id: - return False, "Audiobookshelf did not return a user id – please verify the server URL/token." + return False, "Failed to create user on server" + inv = Invitation.query.filter_by(code=code).first() - # ------------------------------------------------------------------ - # 1) Restrict library access (if requested) - # ------------------------------------------------------------------ - current_server_id = getattr(self, "server_id", None) + # Set library access if inv.libraries: - # Use libraries tied to the invite for THIS server - lib_ids = [lib.external_id for lib in inv.libraries if lib.server_id == current_server_id] + lib_ids = [lib.external_id for lib in inv.libraries if lib.server_id == server_id] else: - # Fallback to all *enabled* libraries for this server lib_ids = [ lib.external_id - for lib in Library.query.filter_by( - enabled=True, - server_id=current_server_id, - ).all() + for lib in Library.query.filter_by(enabled=True, server_id=server_id).all() ] - - # Apply the permission patch – empty list → all libraries self._set_specific_libraries(user_id, lib_ids) - # ------------------------------------------------------------------ - # 2) Calculate expiry time (optional duration on invite) - # ------------------------------------------------------------------ - import datetime as _dt - + # Calculate expiry expires = None if inv.duration: try: + import datetime as _dt expires = _dt.datetime.utcnow() + _dt.timedelta(days=int(inv.duration)) except Exception: - logging.warning("ABS: invalid duration on invite %s", inv.code) + pass - # ------------------------------------------------------------------ - # 3) Store locally - # ------------------------------------------------------------------ + # Store locally local = User( token=user_id, username=username, email=email, code=code, expires=expires, - server_id=getattr(self, "server_id", None), + server_id=server_id, ) db.session.add(local) - try: - db.session.commit() - except Exception: - db.session.rollback() - logging.exception("ABS join failed during DB commit") - return False, "Internal error while saving the account." + db.session.commit() - # 4) mark invite used self._mark_invite_used(inv, local) - return True, "" + except Exception as exc: - logging.error("ABS join failed: %s", exc, exc_info=True) - return False, "Failed to create user – please contact the admin." + logging.error("ABS join failed: %s", exc) + db.session.rollback() + return False, "Failed to create user" def now_playing(self) -> list[dict]: """Return active sessions (updated within the last minute). diff --git a/app/services/media/jellyfin.py b/app/services/media/jellyfin.py index 6fe4c3cec..2bab07928 100644 --- a/app/services/media/jellyfin.py +++ b/app/services/media/jellyfin.py @@ -19,7 +19,6 @@ class JellyfinClient(RestApiMixin): """Wrapper around the Jellyfin REST API using credentials from Settings.""" def __init__(self, *args, **kwargs): - # Ensure default url/token keys if caller didn't override. if "url_key" not in kwargs: kwargs["url_key"] = "server_url" if "token_key" not in kwargs: @@ -27,8 +26,6 @@ class JellyfinClient(RestApiMixin): super().__init__(*args, **kwargs) - # RestApiMixin overrides ------------------------------------------- - def _headers(self) -> dict[str, str]: # type: ignore[override] return {"X-Emby-Token": self.token} if self.token else {} @@ -39,17 +36,7 @@ class JellyfinClient(RestApiMixin): } def scan_libraries(self, url: str = None, token: str = None) -> dict[str, str]: - """Scan available libraries on this Jellyfin server. - - Args: - url: Optional server URL override - token: Optional API token override - - Returns: - dict: Library name -> library ID mapping - """ if url and token: - # Use override credentials for scanning headers = {"X-Emby-Token": token} response = requests.get( f"{url.rstrip('/')}/Library/MediaFolders", @@ -59,7 +46,6 @@ class JellyfinClient(RestApiMixin): response.raise_for_status() items = response.json()["Items"] else: - # Use saved credentials items = self.get("/Library/MediaFolders").json()["Items"] return {item["Name"]: item["Id"] for item in items} @@ -97,7 +83,7 @@ class JellyfinClient(RestApiMixin): return self.post(f"/Users/{jf_id}", json=current).json() def list_users(self) -> list[User]: - """Sync users from Jellyfin into the local DB and return the list of User records.""" + server_id = getattr(self, 'server_id', None) jf_users = {u["Id"]: u for u in self.get("/Users").json()} for jf in jf_users.values(): @@ -108,48 +94,30 @@ class JellyfinClient(RestApiMixin): username=jf["Name"], email="empty", code="empty", - server_id=getattr(self, 'server_id', None), + server_id=server_id, ) db.session.add(new) - db.session.commit() - # delete local users for this server that no longer exist upstream - to_check = ( - User.query - .filter(User.server_id == getattr(self, 'server_id', None)) - .all() - ) + to_check = User.query.filter(User.server_id == server_id).all() for dbu in to_check: if dbu.token not in jf_users: db.session.delete(dbu) + db.session.commit() - - return ( - User.query - .filter(User.server_id == getattr(self, 'server_id', None)) - .all() - ) - - # --- helpers ----------------------------------------------------- + return User.query.filter(User.server_id == server_id).all() def _password_for_db(self, password: str) -> str: - """Return the password value to store in the local DB.""" return password @staticmethod def _mark_invite_used(inv: Invitation, user: User) -> None: - """Mark invitation consumed for the Jellyfin server only.""" inv.used_by = user - mark_server_used(inv, getattr(user, "server_id", None)) + mark_server_used(inv, user.server_id) @staticmethod def _folder_name_to_id(name: str, cache: dict[str, str]) -> str | None: - """Resolve a folder name or ID to the server ID.""" - - # Allow passing the actual ID directly if name in cache.values(): return name - return cache.get(name) def _set_specific_folders(self, user_id: str, names: list[str]): @@ -175,9 +143,7 @@ class JellyfinClient(RestApiMixin): # --- public sign-up --------------------------------------------- - def join( - self, username: str, password: str, confirm: str, email: str, code: str - ) -> tuple[bool, str]: + def join(self, username: str, password: str, confirm: str, email: str, code: str) -> tuple[bool, str]: if not EMAIL_RE.fullmatch(email): return False, "Invalid e-mail address." if not 8 <= len(password) <= 20: @@ -189,44 +155,40 @@ class JellyfinClient(RestApiMixin): if not ok: return False, msg + server_id = getattr(self, 'server_id', None) existing = User.query.filter( or_(User.username == username, User.email == email), - User.server_id == getattr(self, 'server_id', None) + User.server_id == server_id ).first() if existing: return False, "User or e-mail already exists." try: user_id = self.create_user(username, password) - inv = Invitation.query.filter_by(code=code).first() - current_server_id = getattr(self, "server_id", None) if inv.libraries: - sections = [lib.external_id for lib in inv.libraries if lib.server_id == current_server_id] + sections = [lib.external_id for lib in inv.libraries if lib.server_id == server_id] else: sections = [ lib.external_id - for lib in Library.query.filter_by(enabled=True, server_id=current_server_id).all() + for lib in Library.query.filter_by(enabled=True, server_id=server_id).all() ] self._set_specific_folders(user_id, sections) - # Apply download / Live TV permissions allow_downloads = bool(getattr(inv, 'jellyfin_allow_downloads', False)) - allow_live_tv = bool(getattr(inv, 'jellyfin_allow_live_tv', False)) - # fall back to current server defaults if invite flags are false - current_server = None - if current_server_id: - from app.models import MediaServer - current_server = MediaServer.query.get(current_server_id) - if current_server: - if not allow_downloads: - allow_downloads = bool(getattr(current_server, 'allow_downloads_jellyfin', False)) - if not allow_live_tv: - allow_live_tv = bool(getattr(current_server, 'allow_tv_jellyfin', False)) + allow_live_tv = bool(getattr(inv, 'jellyfin_allow_live_tv', False)) + + if server_id: + from app.models import MediaServer + current_server = MediaServer.query.get(server_id) + if current_server: + if not allow_downloads: + allow_downloads = bool(getattr(current_server, 'allow_downloads_jellyfin', False)) + if not allow_live_tv: + allow_live_tv = bool(getattr(current_server, 'allow_tv_jellyfin', False)) - # Always update policy to explicitly set permissions (both True and False) current_policy = self.get(f"/Users/{user_id}").json().get("Policy", {}) current_policy["EnableDownloads"] = allow_downloads current_policy["EnableLiveTvAccess"] = allow_live_tv @@ -234,8 +196,7 @@ class JellyfinClient(RestApiMixin): expires = None if inv.duration: - days = int(inv.duration) - expires = datetime.datetime.utcnow() + datetime.timedelta(days=days) + expires = datetime.datetime.utcnow() + datetime.timedelta(days=int(inv.duration)) new_user = self._create_user_with_identity_linking({ 'username': username, @@ -243,61 +204,40 @@ class JellyfinClient(RestApiMixin): 'token': user_id, 'code': code, 'expires': expires, - 'server_id': current_server_id, + 'server_id': server_id, }) db.session.commit() self._mark_invite_used(inv, new_user) - notify( - "New User", - f"User {username} has joined your server! 🎉", - tags="tada", - ) + notify("New User", f"User {username} has joined your server! 🎉", tags="tada") return True, "" - except Exception: # noqa: BLE001 + except Exception: logging.error("Jellyfin join error", exc_info=True) db.session.rollback() return False, "An unexpected error occurred." def _get_artwork_urls(self, item_id: str, media_type: str = "", series_id: str = None) -> dict[str, str | None]: - """ - Get artwork URLs for an item using remote image providers. - - Args: - item_id: The Jellyfin item ID - media_type: The type of media (movie, episode, etc.) - series_id: The series ID for TV episodes - - Returns: - Dictionary with artwork_url, fallback_artwork_url, and thumbnail_url - """ if not item_id: return {"artwork_url": None, "fallback_artwork_url": None, "thumbnail_url": None} - # For TV episodes, use the series poster instead of episode thumbnail poster_item_id = series_id if (media_type == "episode" and series_id) else item_id try: - # Get remote poster images poster_response = self.get(f"/Items/{poster_item_id}/RemoteImages", params={ "type": "Primary", "limit": 2 }).json() - artwork_url = None - fallback_artwork_url = None - thumbnail_url = None + artwork_url = fallback_artwork_url = thumbnail_url = None - # Get the best poster if poster_response.get("Images"): images = poster_response["Images"] if images: artwork_url = images[0].get("Url") fallback_artwork_url = images[0].get("ThumbnailUrl") or artwork_url - # For thumbnails, try backdrops try: backdrop_response = self.get(f"/Items/{poster_item_id}/RemoteImages", params={ "type": "Backdrop", @@ -309,7 +249,6 @@ class JellyfinClient(RestApiMixin): except Exception: pass - # If no thumbnail, use the poster if not thumbnail_url: thumbnail_url = fallback_artwork_url @@ -322,7 +261,6 @@ class JellyfinClient(RestApiMixin): except Exception as e: logging.warning(f"Failed to get remote images for item {item_id}: {e}") - # Simple fallback to direct URLs base_params = f"?api_key={self.token}" if self.token else "" fallback_url = f"{self.url}/Items/{poster_item_id}/Images/Primary{base_params}" return { @@ -332,36 +270,25 @@ class JellyfinClient(RestApiMixin): } def now_playing(self) -> list[dict]: - """Return a list of currently playing sessions from Jellyfin. - - Returns: - list: A list of session dictionaries with standardized keys. - """ try: sessions = self.get("/Sessions").json() now_playing_sessions = [] for session in sessions: - # Only include sessions that are actually playing media if session.get("NowPlayingItem") is None: continue now_playing_item = session["NowPlayingItem"] play_state = session.get("PlayState", {}) - # Calculate progress (0.0 to 1.0) progress = 0.0 if play_state.get("PositionTicks") and now_playing_item.get("RunTimeTicks"): - progress = play_state["PositionTicks"] / now_playing_item["RunTimeTicks"] - progress = max(0.0, min(1.0, progress)) # Clamp between 0 and 1 + progress = max(0.0, min(1.0, play_state["PositionTicks"] / now_playing_item["RunTimeTicks"])) - # Determine media type media_type = now_playing_item.get("Type", "unknown").lower() - # Get media title - handle different types media_title = now_playing_item.get("Name", "Unknown") if media_type == "episode": - # For TV episodes, include series and episode info series_name = now_playing_item.get("SeriesName", "") season_num = now_playing_item.get("ParentIndexNumber", "") episode_num = now_playing_item.get("IndexNumber", "") @@ -371,27 +298,19 @@ class JellyfinClient(RestApiMixin): media_title += f" S{season_num:02d}E{episode_num:02d}" media_title += f" - {now_playing_item.get('Name', '')}" - # Get playback state state = "stopped" if play_state.get("IsPaused"): state = "paused" elif play_state.get("PositionTicks") is not None: state = "playing" - # Get user info user_info = session.get("UserName", "Unknown User") - - # Get session ID session_id = session.get("Id", "") - # ------------------------------------------------------------------ - # Poster & secondary artwork (thumbnail / backdrop) - IMPROVED - # ------------------------------------------------------------------ item_id = now_playing_item.get("Id") - series_id = now_playing_item.get("SeriesId") # For TV episodes + series_id = now_playing_item.get("SeriesId") artwork_info = self._get_artwork_urls(item_id, media_type, series_id) - # Get transcoding information transcoding_info = { "is_transcoding": False, "video_codec": None, @@ -402,7 +321,6 @@ class JellyfinClient(RestApiMixin): "direct_play": True } - # Check transcoding info from session if session.get("TranscodingInfo"): transcode_info = session["TranscodingInfo"] transcoding_info["is_transcoding"] = True @@ -412,7 +330,6 @@ class JellyfinClient(RestApiMixin): transcoding_info["container"] = transcode_info.get("Container") transcoding_info["transcoding_speed"] = transcode_info.get("TranscodingFramerate") - # Check for direct stream vs transcode if session.get("PlayMethod"): play_method = session["PlayMethod"] if play_method == "DirectPlay": @@ -423,7 +340,6 @@ class JellyfinClient(RestApiMixin): if play_method == "Transcode": transcoding_info["is_transcoding"] = True - # Get media stream info if now_playing_item.get("MediaStreams"): for stream in now_playing_item["MediaStreams"]: if stream.get("Type") == "Video" and not transcoding_info["video_codec"]: @@ -432,11 +348,9 @@ class JellyfinClient(RestApiMixin): elif stream.get("Type") == "Audio" and not transcoding_info["audio_codec"]: transcoding_info["audio_codec"] = stream.get("Codec") - # Get container info if not transcoding_info["container"]: transcoding_info["container"] = now_playing_item.get("Container") - # Build standardized session info session_info = { "user_name": user_info, "media_title": media_title, @@ -446,8 +360,8 @@ class JellyfinClient(RestApiMixin): "session_id": session_id, "client": session.get("Client", ""), "device_name": session.get("DeviceName", ""), - "position_ms": play_state.get("PositionTicks", 0) // 10000, # Convert from ticks to ms - "duration_ms": now_playing_item.get("RunTimeTicks", 0) // 10000, # Convert from ticks to ms + "position_ms": play_state.get("PositionTicks", 0) // 10000, + "duration_ms": now_playing_item.get("RunTimeTicks", 0) // 10000, "artwork_url": artwork_info["artwork_url"], "fallback_artwork_url": artwork_info["fallback_artwork_url"], "thumbnail_url": artwork_info["thumbnail_url"], @@ -463,17 +377,6 @@ class JellyfinClient(RestApiMixin): return [] def statistics(self): - """Return essential Jellyfin server statistics for the dashboard. - - Only collects data actually used by the UI: - - Server version for health card - - Active sessions count for health card - - Transcoding sessions count for health card - - Total users count for health card - - Returns: - dict: Server statistics with minimal API calls - """ try: stats = { "library_stats": {}, @@ -482,12 +385,10 @@ class JellyfinClient(RestApiMixin): "content_stats": {} } - # Get sessions once - used for both user and server stats sessions = self.get("/Sessions").json() active_sessions = [s for s in sessions if s.get("NowPlayingItem")] transcoding_sessions = [s for s in sessions if s.get("TranscodingInfo")] - # User statistics - only what's displayed in UI try: users = self.get("/Users").json() stats["user_stats"] = { @@ -496,12 +397,8 @@ class JellyfinClient(RestApiMixin): } except Exception as e: logging.error(f"Failed to get Jellyfin user stats: {e}") - stats["user_stats"] = { - "total_users": 0, - "active_sessions": 0 - } + stats["user_stats"] = {"total_users": 0, "active_sessions": 0} - # Server statistics - only version and transcoding count try: system_info = self.get("/System/Info").json() stats["server_stats"] = { diff --git a/app/services/media/plex.py b/app/services/media/plex.py index b954e65d4..7442b314a 100644 --- a/app/services/media/plex.py +++ b/app/services/media/plex.py @@ -13,33 +13,16 @@ from .client_base import MediaClient, register_media_client from app.services.media.service import get_client_for_media_server from app.services.invites import mark_server_used -# --------------------------------------------------------------------------- -# Monkey-patch: acceptInvite that uses /api/v2/shared_servers routes -# --------------------------------------------------------------------------- def _accept_invite_v2(self: MyPlexAccount, owner): - """ - Accept a pending server share via the v2 API - (list → accept) without hitting the deprecated v1 endpoint. - """ - + """Accept a pending server share via the v2 API.""" base = "https://clients.plex.tv" - # 1) Build the full identity query-string Plex expects. - # Plex returns HTTP 400 unless *all* usual X-Plex-* fields are present - # in the **query string**. The session headers created by PlexAPI - # contain only a subset (sometimes just the token), so we copy what is - # there *and* add sensible defaults for the rest. - params = { k: v for k, v in self._session.headers.items() if k.startswith("X-Plex-") and k != "X-Plex-Provides" } - # ------------------------------------------------------------------ - # Fill in any missing identity fields with fall-back values. They do - # not need to be perfect – Plex validates presence, not content. - # ------------------------------------------------------------------ defaults = { "X-Plex-Product": "Wizarr", "X-Plex-Version": "1.0", @@ -57,19 +40,14 @@ def _accept_invite_v2(self: MyPlexAccount, owner): for key, value in defaults.items(): params.setdefault(key, value) - # ensure we always pass a valid token params["X-Plex-Token"] = self.authToken - - # 2) Normal headers – we keep Accept here, *not* on the query-string hdrs = {"Accept": "application/json"} - # ---- list pending invites ------------------------------------------------ url_list = f"{base}/api/v2/shared_servers/invites/received/pending" resp = self._session.get(url_list, params=params, headers=hdrs) resp.raise_for_status() - invites = resp.json() # list[dict] + invites = resp.json() - # ---- find the invite that matches the owner we were given --------------- def _matches(inv): o = inv.get("owner", {}) return owner in (o.get("username"), o.get("email"), o.get("title"), o.get("friendlyName")) @@ -79,21 +57,17 @@ def _accept_invite_v2(self: MyPlexAccount, owner): except StopIteration: raise ValueError(f"No pending invite from '{owner}' found") - # Each invitation can include one or more shared servers; pick the first shared_servers = inv.get("sharedServers") if not shared_servers: raise ValueError("Invite structure missing 'sharedServers' list") invite_id = shared_servers[0]["id"] - # ---- accept it ----------------------------------------------------------- url_accept = f"{base}/api/v2/shared_servers/{invite_id}/accept" resp = self._session.post(url_accept, params=params, headers=hdrs) resp.raise_for_status() return resp - -# Activate the patch MyPlexAccount.acceptInvite = _accept_invite_v2 @register_media_client("plex") @@ -101,24 +75,12 @@ class PlexClient(MediaClient): """Wrapper that connects to Plex using admin credentials.""" def __init__(self, *args, **kwargs): - """Initialise the Plex client. - - ``MediaClient`` now supports passing a fully populated - ``MediaServer`` row via ``media_server=…``. We therefore accept an - arbitrary signature and forward it to the superclass so that callers - like ``get_client_for_media_server`` can leverage this without - modification here. - """ - - # Keep legacy fallback behaviour (url_key / token_key) by providing - # the default kwargs if the caller did *not* override them. if "url_key" not in kwargs: kwargs["url_key"] = "server_url" if "token_key" not in kwargs: kwargs["token_key"] = "api_key" super().__init__(*args, **kwargs) - self._server = None self._admin = None @@ -135,57 +97,25 @@ class PlexClient(MediaClient): return self._admin def libraries(self) -> dict[str, str]: - """Return a mapping of external_id to display name for each Plex library section.""" return {lib.title: lib.title for lib in self.server.library.sections()} def scan_libraries(self, url: str = None, token: str = None) -> dict[str, str]: - """Scan available libraries on this Plex server. - - Args: - url: Optional server URL override - token: Optional API token override - - Returns: - dict: Library name -> library title mapping - """ if url and token: - # Use override credentials for scanning try: from plexapi.server import PlexServer temp_server = PlexServer(url, token) return {lib.title: lib.title for lib in temp_server.library.sections()} except Exception as e: - logging.error(f"Failed to scan Plex libraries with override credentials: {e}") + logging.error(f"Failed to scan Plex libraries: {e}") return {} else: - # Use saved credentials return self.libraries() def create_user(self, *args, **kwargs): - """Plex does not support direct user creation; use invite_friend or invite_home.""" - raise NotImplementedError( - "PlexClient does not support create_user; use invite_friend or invite_home" - ) + raise NotImplementedError("PlexClient does not support create_user; use invite_friend or invite_home") def join(self, username: str, password: str, confirm: str, email: str, code: str) -> tuple[bool, str]: - """Plex does not support direct user creation via join. - - Plex uses an email-based invitation system instead of direct user creation. - Users must be invited via email and then accept the invitation through Plex's UI. - - Args: - username: Username for the new account (unused for Plex) - password: Password for the new account (unused for Plex) - confirm: Password confirmation (unused for Plex) - email: Email address for the invitation - code: Invitation code - - Returns: - tuple: (False, error_message) - Plex doesn't support direct joining - """ - return False, ("Plex does not support direct user creation. " - "Users must be invited via email and accept through Plex's interface. " - "Please use the admin panel to send Plex invitations.") + return False, "Plex does not support direct user creation. Users must be invited via email." def invite_friend(self, email: str, sections: list[str], allow_sync: bool, allow_channels: bool): self.admin.inviteFriend( user=email, server=self.server, @@ -212,13 +142,6 @@ class PlexClient(MediaClient): "allowChannels": plex_user.allowChannels, "allowSync": plex_user.allowSync, }, - # Use an empty list to indicate that we could not (or do not need to) resolve - # the list of library section IDs for this Plex user. Returning a non-iterable - # value such as the previous string "na" broke downstream queries that expect - # an *iterable* when building SQLAlchemy ``IN ( ... )`` expressions. - # - # An empty list is safe because the admin UI will simply render “all libraries” - # for Plex users unless a concrete subset is provided. "Policy": {"sections": []}, } @@ -292,33 +215,22 @@ class PlexClient(MediaClient): def now_playing(self) -> list[dict]: - """Return a list of currently playing sessions from Plex. - - Returns: - list: A list of session dictionaries with standardized keys. - """ try: sessions = self.server.sessions() now_playing_sessions = [] for session in sessions: - # Only include sessions that are actively playing if not hasattr(session, 'viewOffset') or session.viewOffset is None: continue - # Calculate progress (0.0 to 1.0) progress = 0.0 if hasattr(session, 'duration') and session.duration and session.viewOffset: - progress = session.viewOffset / session.duration - progress = max(0.0, min(1.0, progress)) # Clamp between 0 and 1 + progress = max(0.0, min(1.0, session.viewOffset / session.duration)) - # Determine media type media_type = getattr(session, 'type', 'unknown').lower() - # Get media title - handle different types media_title = getattr(session, 'title', 'Unknown') if media_type == 'episode': - # For TV episodes, include series and episode info grandparent_title = getattr(session, 'grandparentTitle', '') season_num = getattr(session, 'parentIndex', None) episode_num = getattr(session, 'index', None) @@ -328,54 +240,32 @@ class PlexClient(MediaClient): media_title += f" S{season_num:02d}E{episode_num:02d}" media_title += f" - {getattr(session, 'title', '')}" - # Get playback state from players - state = "stopped" players = getattr(session, 'players', []) + state = "stopped" if players: player_state = getattr(players[0], 'state', 'stopped') - if player_state == 'paused': - state = "paused" - elif player_state == 'playing': - state = "playing" - elif player_state == 'buffering': - state = "buffering" + state = {"paused": "paused", "playing": "playing", "buffering": "buffering"}.get(player_state, "stopped") - # Get user info user_info = "Unknown User" if hasattr(session, 'usernames') and session.usernames: user_info = session.usernames[0] elif hasattr(session, 'users') and session.users: user_info = session.users[0].title - # Get session ID - session_id = getattr(session, 'sessionKey', '') - - # Get client info - client_name = "" - device_name = "" + client_name = device_name = "" if players: client_name = getattr(players[0], 'product', '') device_name = getattr(players[0], 'title', '') - # Resolve the correct artwork URL. Prefer the helper properties PlexAPI - # exposes (``thumbUrl``) as they already include the base server URL and - # authentication token. However, if the session provides Image tags we - # prefer the first one marked as a *cover poster* to achieve a richer - # artwork experience. - artwork_url = None - # 0) Prefer the first Image tag whose ``type`` is "coverPoster" (if any) images_attr = getattr(session, "image", None) if images_attr: images_list = images_attr if isinstance(images_attr, (list, tuple, set)) else [images_attr] for img in images_list: if getattr(img, "type", None) == "coverPoster": - # Attempt to resolve an absolute URL for the image img_key = getattr(img, "key", None) or getattr(img, "thumb", None) if img_key: - artwork_url = ( - img_key if str(img_key).startswith("http") else self.server.url(img_key, includeToken=True) - ) + artwork_url = img_key if str(img_key).startswith("http") else self.server.url(img_key, includeToken=True) elif getattr(img, "thumbUrl", None): artwork_url = img.thumbUrl elif getattr(img, "url", None): @@ -383,8 +273,6 @@ class PlexClient(MediaClient): if artwork_url: break - # 1) Fallback to more reliable poster-like attributes (already absolute path - # or converted to absolute): for attr in ("grandparentThumb", "parentThumb", "art"): if artwork_url is not None: break @@ -392,11 +280,9 @@ class PlexClient(MediaClient): if val: artwork_url = val if str(val).startswith("http") else self.server.url(val, includeToken=True) - # 2) Ultimate fallback – PlexAPI's thumbUrl (may return a frame thumbnail). if artwork_url is None and getattr(session, "thumbUrl", None): artwork_url = session.thumbUrl - # Simplified transcoding / media details ----------------------- is_transcoding = bool(getattr(session, 'transcodeSessions', [])) transcode_speed = None if is_transcoding: @@ -420,14 +306,13 @@ class PlexClient(MediaClient): "direct_play": not is_transcoding, } - # Build standardized session info session_info = { "user_name": user_info, "media_title": media_title, "media_type": media_type, "progress": progress, "state": state, - "session_id": str(session_id), + "session_id": str(getattr(session, 'sessionKey', '')), "client": client_name, "device_name": device_name, "position_ms": getattr(session, 'viewOffset', 0), @@ -446,17 +331,6 @@ class PlexClient(MediaClient): return [] def statistics(self): - """Return essential Plex server statistics for the dashboard. - - Only collects data actually used by the UI: - - Server version for health card - - Active sessions count for health card - - Transcoding sessions count for health card - - Total users count for health card - - Returns: - dict: Server statistics with minimal API calls - """ try: stats = { "library_stats": {}, @@ -465,25 +339,19 @@ class PlexClient(MediaClient): "content_stats": {} } - # Get sessions once - used for both user and server stats sessions = self.server.sessions() transcode_sessions = self.server.transcodeSessions() - # User statistics - only what's displayed in UI try: users = self.server.myPlexAccount().users() stats["user_stats"] = { - "total_users": len(users) + 1, # +1 for account owner + "total_users": len(users) + 1, "active_sessions": len(sessions) } except Exception as e: logging.error(f"Failed to get Plex user stats: {e}") - stats["user_stats"] = { - "total_users": 0, - "active_sessions": 0 - } + stats["user_stats"] = {"total_users": 0, "active_sessions": 0} - # Server statistics - only version and transcoding count try: stats["server_stats"] = { "version": getattr(self.server, 'version', 'Unknown'), @@ -509,7 +377,6 @@ class PlexClient(MediaClient): # ─── Invite & onboarding ──────────────────────────────────────────────── def handle_oauth_token(app, token: str, code: str) -> None: - """Called after Plex OAuth handshake; create DB user and invite to Plex.""" with app.app_context(): account = MyPlexAccount(token=token) email = account.email @@ -518,7 +385,6 @@ def handle_oauth_token(app, token: str, code: str) -> None: server = inv.server if inv and inv.server else MediaServer.query.first() server_id = server.id if server else None - # remove any previous account with same email on this server db.session.query(User).filter( User.email == email, User.server_id == server_id @@ -531,7 +397,6 @@ def handle_oauth_token(app, token: str, code: str) -> None: if duration else None ) - # Use the identity linking helper for multi-server invitations client = PlexClient(media_server=server) new_user = client._create_user_with_identity_linking({ 'token': token, @@ -562,11 +427,9 @@ def _invite_user(email: str, code: str, user_id: int, server: MediaServer) -> No inv = Invitation.query.filter_by(code=code).first() client = get_client_for_media_server(server) - # libraries list libs = [lib.external_id for lib in inv.libraries if lib.server_id == server.id] if inv.libraries else [] if not libs: - # Fallback to *all* enabled libraries for this server libs = [ lib.external_id for lib in Library.query.filter_by(enabled=True, server_id=server.id).all() @@ -586,10 +449,7 @@ def _invite_user(email: str, code: str, user_id: int, server: MediaServer) -> No user = User.query.get(user_id) inv.used_by = user - # Mark invite consumed for *this* server (multi-server aware) mark_server_used(inv, server.id) - - # clear cached user list so admin UI shows the new invite PlexClient.list_users.cache_clear() db.session.commit() diff --git a/app/templates/components/passkey_management.html b/app/templates/components/passkey_management.html index 2bd0f5000..688b61ec0 100644 --- a/app/templates/components/passkey_management.html +++ b/app/templates/components/passkey_management.html @@ -2,7 +2,7 @@
-