mirror of
https://github.com/wizarrrr/wizarr.git
synced 2026-07-31 07:17:10 -04:00
fix media server
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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"] = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
<svg class="w-6 h-6 text-gray-800" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<svg class="w-6 h-6 text-gray-600" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14v3m4-6V7a3 3 0 1 1 6 0v4M5 11h10a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1Z"/>
|
||||
</svg>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user