mirror of
https://github.com/wizarrrr/wizarr.git
synced 2025-12-23 23:59:23 -05:00
feat: allow user edit
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -427,3 +427,6 @@ tests/test-media/**/*.mp3
|
||||
|
||||
|
||||
CLAUDE.md.old
|
||||
|
||||
# Internal documentation (AI-generated, not for version control)
|
||||
docs-internal/
|
||||
|
||||
@@ -19,7 +19,7 @@ def create_app(config_object=DevelopmentConfig):
|
||||
|
||||
if show_startup:
|
||||
logger.welcome(os.getenv("APP_VERSION", "dev"))
|
||||
logger.start_sequence(total_steps=8)
|
||||
logger.start_sequence(total_steps=10)
|
||||
|
||||
# Step 1: Configure logging
|
||||
if show_startup:
|
||||
@@ -110,7 +110,25 @@ def create_app(config_object=DevelopmentConfig):
|
||||
# Non-fatal – log and continue startup to avoid blocking the app
|
||||
logger.warning(f"Wizard step migration failed: {exc}")
|
||||
|
||||
# Step 8: Initialize Plus features if enabled
|
||||
# Step 8: Scan libraries for all media servers
|
||||
if show_startup:
|
||||
logger.step("Scanning media server libraries", "📚")
|
||||
try:
|
||||
from .services.library_scanner import scan_all_server_libraries
|
||||
|
||||
total_scanned, _ = scan_all_server_libraries(show_logs=show_startup)
|
||||
|
||||
if show_startup:
|
||||
if total_scanned > 0:
|
||||
logger.success(f"Scanned {total_scanned} libraries")
|
||||
else:
|
||||
logger.info("No media servers configured")
|
||||
except Exception as exc:
|
||||
# Non-fatal – log and continue startup to avoid blocking the app
|
||||
if show_startup:
|
||||
logger.warning(f"Library scanning failed: {exc}")
|
||||
|
||||
# Step 9: Initialize Plus features if enabled
|
||||
if show_startup:
|
||||
logger.step("Checking for Plus features", "⭐")
|
||||
|
||||
@@ -141,7 +159,7 @@ def create_app(config_object=DevelopmentConfig):
|
||||
elif show_startup:
|
||||
logger.info("Plus features disabled")
|
||||
|
||||
# Step 9: Show scheduler status and complete startup
|
||||
# Step 10: Show scheduler status and complete startup
|
||||
if show_startup:
|
||||
logger.step("Finalizing application setup", "✨")
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from urllib.parse import urlparse
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
@@ -482,18 +483,12 @@ def user_detail(db_id: int):
|
||||
• GET → return the enhanced per-server expiry edit modal
|
||||
• POST → update per-server expiry then return the entire card grid
|
||||
"""
|
||||
from app.models import Invitation
|
||||
|
||||
user = db.get_or_404(User, db_id)
|
||||
|
||||
if request.method == "POST":
|
||||
# Handle per-server expiry updates
|
||||
|
||||
# Find the invitation this user was created from
|
||||
invitation = None
|
||||
if user.code:
|
||||
invitation = Invitation.query.filter_by(code=user.code).first()
|
||||
|
||||
# Update expiry for the user's specific server
|
||||
raw_expires = request.form.get("expires")
|
||||
if raw_expires:
|
||||
@@ -535,19 +530,213 @@ def user_detail(db_id: int):
|
||||
# Single user, no identity linking
|
||||
related_users = [user]
|
||||
|
||||
# Get the invitation to show additional context
|
||||
invitation = None
|
||||
if user.code:
|
||||
invitation = Invitation.query.filter_by(code=user.code).first()
|
||||
# Pre-load libraries for each user to avoid client-side fetching
|
||||
user_libraries_map = {}
|
||||
for related_user in related_users:
|
||||
if related_user.server:
|
||||
libraries = (
|
||||
Library.query.filter_by(server_id=related_user.server.id, enabled=True)
|
||||
.order_by(Library.name)
|
||||
.all()
|
||||
)
|
||||
accessible_libraries = related_user.get_accessible_libraries()
|
||||
user_libraries_map[related_user.id] = {
|
||||
"libraries": libraries,
|
||||
"accessible_libraries": accessible_libraries or [],
|
||||
}
|
||||
else:
|
||||
user_libraries_map[related_user.id] = {
|
||||
"libraries": [],
|
||||
"accessible_libraries": [],
|
||||
}
|
||||
|
||||
return render_template(
|
||||
"admin/user_modal.html",
|
||||
user=user,
|
||||
related_users=related_users,
|
||||
invitation=invitation,
|
||||
user_libraries_map=user_libraries_map,
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route("/user/<int:db_id>/libraries", methods=["GET"])
|
||||
@login_required
|
||||
def user_libraries(db_id: int):
|
||||
"""Return available libraries for the user's server as JSON."""
|
||||
user = db.get_or_404(User, db_id)
|
||||
|
||||
if not user.server:
|
||||
return jsonify({"libraries": [], "accessible_libraries": []}), 200
|
||||
|
||||
try:
|
||||
# Get all enabled libraries for this server
|
||||
libraries = (
|
||||
Library.query.filter_by(server_id=user.server.id, enabled=True)
|
||||
.order_by(Library.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Get user's current accessible libraries
|
||||
# If accessible_libraries column is NULL, user has access to all libraries
|
||||
accessible_libraries = user.get_accessible_libraries()
|
||||
|
||||
# Return empty list if None (meaning all libraries accessible)
|
||||
if accessible_libraries is None:
|
||||
accessible_libraries = []
|
||||
|
||||
logging.info(
|
||||
f"Loading libraries for user {user.username} (ID: {db_id}): "
|
||||
f"{len(libraries)} total libraries, "
|
||||
f"accessible: {accessible_libraries or 'all'}"
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"libraries": [{"id": lib.id, "name": lib.name} for lib in libraries],
|
||||
"accessible_libraries": accessible_libraries,
|
||||
}
|
||||
), 200
|
||||
except Exception as exc:
|
||||
logging.error(f"Failed to load libraries for user {db_id}: {exc}")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
|
||||
@admin_bp.route("/user/<int:db_id>/permissions", methods=["POST"])
|
||||
@login_required
|
||||
def update_user_permissions(db_id: int):
|
||||
"""Update user permissions (downloads, live_tv, camera_upload)."""
|
||||
from app.services.media.service import get_client_for_media_server
|
||||
|
||||
user = db.get_or_404(User, db_id)
|
||||
|
||||
if not user.server:
|
||||
return Response("User has no associated server", status=400)
|
||||
|
||||
# Get permission type and value from form
|
||||
permission_type = request.form.get("permission_type")
|
||||
enabled = request.form.get("enabled") == "true"
|
||||
|
||||
if permission_type not in (
|
||||
"allow_downloads",
|
||||
"allow_live_tv",
|
||||
"allow_camera_upload",
|
||||
):
|
||||
return Response("Invalid permission type", status=400)
|
||||
|
||||
try:
|
||||
# Update database
|
||||
setattr(user, permission_type, enabled)
|
||||
db.session.commit()
|
||||
|
||||
# Update media server via API (with graceful error handling)
|
||||
try:
|
||||
client = get_client_for_media_server(user.server)
|
||||
|
||||
# Use the generic interface - all clients support this now
|
||||
user_identifier = (
|
||||
user.email if user.server.server_type == "plex" else user.token
|
||||
)
|
||||
permissions = {
|
||||
"allow_downloads": user.allow_downloads or False,
|
||||
"allow_live_tv": user.allow_live_tv or False,
|
||||
"allow_camera_upload": user.allow_camera_upload or False,
|
||||
}
|
||||
|
||||
success = client.update_user_permissions(user_identifier, permissions)
|
||||
if not success:
|
||||
logging.warning(
|
||||
f"Media server {user.server.server_type} does not support permission updates or update failed"
|
||||
)
|
||||
except Exception as api_exc:
|
||||
# Log but don't fail - database update is more important
|
||||
logging.warning(
|
||||
f"Could not update permissions on media server for user {user.username}: {api_exc}"
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f"Updated {permission_type} to {enabled} for user {user.username} (ID: {db_id})"
|
||||
)
|
||||
|
||||
response = Response("", status=200)
|
||||
response.headers["HX-Trigger"] = "refreshUserTable"
|
||||
return response
|
||||
|
||||
except Exception as exc:
|
||||
logging.error(f"Failed to update user permissions: {exc}")
|
||||
db.session.rollback()
|
||||
return Response(f"Failed to update permissions: {exc!s}", status=500)
|
||||
|
||||
|
||||
@admin_bp.route("/user/<int:db_id>/libraries", methods=["POST"])
|
||||
@login_required
|
||||
def update_user_libraries(db_id: int):
|
||||
"""Update user's accessible libraries."""
|
||||
from app.services.media.service import get_client_for_media_server
|
||||
|
||||
user = db.get_or_404(User, db_id)
|
||||
|
||||
if not user.server:
|
||||
return Response("User has no associated server", status=400)
|
||||
|
||||
try:
|
||||
# Get selected library IDs from form
|
||||
library_ids = request.form.getlist("library_ids[]")
|
||||
logging.info(f"Received library_ids from form: {library_ids}")
|
||||
|
||||
# Convert string IDs to integers
|
||||
try:
|
||||
library_ids = [int(lid) for lid in library_ids if lid]
|
||||
except (ValueError, TypeError):
|
||||
library_ids = []
|
||||
|
||||
logging.info(f"Converted to integers: {library_ids}")
|
||||
|
||||
# If no libraries selected, it means "all libraries" (None)
|
||||
if not library_ids:
|
||||
user.set_accessible_libraries(None)
|
||||
library_names = None
|
||||
else:
|
||||
# Convert IDs to library names
|
||||
libraries = Library.query.filter(Library.id.in_(library_ids)).all()
|
||||
library_names = [lib.name for lib in libraries]
|
||||
logging.info(f"Converted to library names: {library_names}")
|
||||
user.set_accessible_libraries(library_names)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Update media server via API (with graceful error handling)
|
||||
try:
|
||||
client = get_client_for_media_server(user.server)
|
||||
|
||||
# Use the generic interface - all clients support this now
|
||||
user_identifier = (
|
||||
user.email if user.server.server_type == "plex" else user.token
|
||||
)
|
||||
|
||||
success = client.update_user_libraries(user_identifier, library_names)
|
||||
if not success:
|
||||
logging.warning(
|
||||
f"Media server {user.server.server_type} does not support library updates or update failed"
|
||||
)
|
||||
except Exception as api_exc:
|
||||
# Log but don't fail - database update is more important
|
||||
logging.warning(
|
||||
f"Could not update library access on media server for user {user.username}: {api_exc}"
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f"Updated library access for user {user.username} (ID: {db_id}): {library_names or 'all libraries'}"
|
||||
)
|
||||
|
||||
response = Response("", status=200)
|
||||
response.headers["HX-Trigger"] = "refreshUserTable"
|
||||
return response
|
||||
|
||||
except Exception as exc:
|
||||
logging.error(f"Failed to update library access: {exc}")
|
||||
db.session.rollback()
|
||||
return Response(f"Failed to update library access: {exc!s}", status=500)
|
||||
|
||||
|
||||
@admin_bp.post("/invite/scan-libraries")
|
||||
@login_required
|
||||
def invite_scan_libraries():
|
||||
@@ -577,16 +766,19 @@ def invite_scan_libraries():
|
||||
logging.warning("Library scan failed for %s: %s", server.name, exc)
|
||||
items = []
|
||||
|
||||
# Delete all old libraries for this server and insert fresh ones
|
||||
Library.query.filter_by(server_id=server.id).delete()
|
||||
db.session.flush()
|
||||
|
||||
# Insert fresh libraries with correct external IDs
|
||||
for fid, name in items:
|
||||
lib = Library.query.filter_by(external_id=fid, server_id=server.id).first()
|
||||
if lib:
|
||||
lib.name = name
|
||||
else:
|
||||
lib = Library()
|
||||
lib.external_id = fid
|
||||
lib.name = name
|
||||
lib.server_id = server.id
|
||||
db.session.add(lib)
|
||||
lib = Library()
|
||||
lib.external_id = fid
|
||||
lib.name = name
|
||||
lib.server_id = server.id
|
||||
lib.enabled = True
|
||||
db.session.add(lib)
|
||||
|
||||
db.session.flush()
|
||||
server_libs[server.id] = (
|
||||
Library.query.filter_by(server_id=server.id).order_by(Library.name).all()
|
||||
|
||||
@@ -153,18 +153,20 @@ def scan_server_libraries(server_id):
|
||||
items.items() if isinstance(items, dict) else [(name, name) for name in items]
|
||||
)
|
||||
|
||||
seen_ids = set()
|
||||
# Delete all old libraries for this server and insert fresh ones
|
||||
# This avoids conflicts during migration from name-based to ID-based external_id
|
||||
Library.query.filter_by(server_id=server.id).delete()
|
||||
db.session.flush()
|
||||
|
||||
# Insert fresh libraries with correct external IDs
|
||||
for fid, name in pairs:
|
||||
seen_ids.add(fid)
|
||||
lib = Library.query.filter_by(external_id=fid, server_id=server.id).first()
|
||||
if lib:
|
||||
lib.name = name
|
||||
else:
|
||||
lib = Library()
|
||||
lib.external_id = fid
|
||||
lib.name = name
|
||||
lib.server_id = server.id
|
||||
db.session.add(lib)
|
||||
lib = Library()
|
||||
lib.external_id = fid
|
||||
lib.name = name
|
||||
lib.server_id = server.id
|
||||
lib.enabled = True
|
||||
db.session.add(lib)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Render checkboxes partial (reuse existing partials)
|
||||
|
||||
@@ -234,22 +234,18 @@ def scan_libraries():
|
||||
error_message = str(exc) if str(exc) else _("Library scan failed")
|
||||
return f"<div class='text-red-500 p-3 border border-red-300 rounded-lg bg-red-50 dark:bg-red-900 dark:border-red-700'><strong>{_('Error')}:</strong> {error_message}</div>"
|
||||
|
||||
# 3) upsert into our Library table
|
||||
seen_ids = set()
|
||||
# 3) Delete all old libraries and insert fresh ones
|
||||
# Note: This assumes single-server legacy setup (settings route)
|
||||
Library.query.delete()
|
||||
db.session.flush()
|
||||
|
||||
# Insert fresh libraries with correct external IDs
|
||||
for fid, name in items:
|
||||
seen_ids.add(fid)
|
||||
lib = Library.query.filter_by(external_id=fid).first()
|
||||
if lib:
|
||||
lib.name = name # keep names fresh
|
||||
else:
|
||||
lib = Library()
|
||||
lib.external_id = fid
|
||||
lib.name = name
|
||||
db.session.add(lib)
|
||||
# delete any that upstream no longer offers
|
||||
Library.query.filter(~Library.external_id.in_(seen_ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
lib = Library()
|
||||
lib.external_id = fid
|
||||
lib.name = name
|
||||
lib.enabled = True
|
||||
db.session.add(lib)
|
||||
db.session.commit()
|
||||
|
||||
# 4) render checkboxes off our Library.enabled
|
||||
|
||||
@@ -17,12 +17,12 @@ try:
|
||||
except ImportError: # pragma: no cover - during unit tests
|
||||
db = None # type: ignore
|
||||
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.activity.domain.models import ActivityQuery
|
||||
from app.models import ActivitySession
|
||||
from app.services.activity.identity_resolution import apply_identity_resolution
|
||||
|
||||
from sqlalchemy import or_
|
||||
|
||||
|
||||
class ActivityQueryService:
|
||||
"""Encapsulates filterable queries over activity sessions."""
|
||||
@@ -58,9 +58,14 @@ class ActivityQueryService:
|
||||
if query.server_ids:
|
||||
filters.append(ActivitySession.server_id.in_(query.server_ids))
|
||||
if query.user_names:
|
||||
# Replace exact match with partial, case-insensitive match for each name
|
||||
# Replace exact match with partial, case-insensitive match for each name
|
||||
filters.append(
|
||||
or_(*[ActivitySession.user_name.ilike(f"%{name}%") for name in query.user_names])
|
||||
or_(
|
||||
*[
|
||||
ActivitySession.user_name.ilike(f"%{name}%")
|
||||
for name in query.user_names
|
||||
]
|
||||
)
|
||||
)
|
||||
if query.media_types:
|
||||
filters.append(ActivitySession.media_type.in_(query.media_types))
|
||||
|
||||
77
app/services/library_scanner.py
Normal file
77
app/services/library_scanner.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Library scanning service for media servers.
|
||||
|
||||
This service handles scanning and synchronizing library metadata from media servers
|
||||
into the local database during application startup.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def scan_all_server_libraries(show_logs: bool = True) -> tuple[int, list[str]]:
|
||||
"""Scan libraries for all configured media servers.
|
||||
|
||||
Args:
|
||||
show_logs: Whether to output log messages during scanning
|
||||
|
||||
Returns:
|
||||
Tuple of (total_scanned, error_messages)
|
||||
- total_scanned: Number of libraries successfully scanned
|
||||
- error_messages: List of error messages for failed scans
|
||||
"""
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Library, MediaServer
|
||||
from app.services.media.service import get_client_for_media_server
|
||||
|
||||
# Check if the library table exists (in case migrations haven't run yet)
|
||||
inspector = inspect(db.engine)
|
||||
if not inspector.has_table("library"):
|
||||
if show_logs:
|
||||
logger.info("Library table doesn't exist yet - skipping scan")
|
||||
raise Exception("Library table not found - run migrations first")
|
||||
|
||||
servers = MediaServer.query.all()
|
||||
total_scanned = 0
|
||||
errors = []
|
||||
|
||||
for server in servers:
|
||||
try:
|
||||
client = get_client_for_media_server(server)
|
||||
libraries_dict = client.libraries() # {external_id: name}
|
||||
|
||||
# Delete ALL old libraries for this server to avoid conflicts
|
||||
# This ensures a clean slate for the new external_id format
|
||||
old_count = Library.query.filter_by(server_id=server.id).count()
|
||||
Library.query.filter_by(server_id=server.id).delete()
|
||||
db.session.flush()
|
||||
|
||||
# Insert fresh libraries with correct global IDs
|
||||
for external_id, name in libraries_dict.items():
|
||||
lib = Library(
|
||||
external_id=external_id,
|
||||
name=name,
|
||||
server_id=server.id,
|
||||
enabled=True,
|
||||
)
|
||||
db.session.add(lib)
|
||||
total_scanned += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if show_logs:
|
||||
logger.info(
|
||||
f"Refreshed {len(libraries_dict)} libraries for {server.name} "
|
||||
f"(removed {old_count} old entries)"
|
||||
)
|
||||
except Exception as server_exc:
|
||||
# Rollback on error to keep session clean
|
||||
db.session.rollback()
|
||||
error_msg = f"Failed to scan libraries for {server.name}: {server_exc}"
|
||||
errors.append(error_msg)
|
||||
if show_logs:
|
||||
logger.warning(error_msg)
|
||||
|
||||
return total_scanned, errors
|
||||
@@ -468,6 +468,125 @@ class AudiobookshelfClient(RestApiMixin):
|
||||
logging.error("ABS: failed to update user %s – %s", user_id, exc)
|
||||
raise
|
||||
|
||||
def update_user_permissions(
|
||||
self, user_id: str, permissions: dict[str, bool]
|
||||
) -> bool:
|
||||
"""Update user permissions on Audiobookshelf.
|
||||
|
||||
Args:
|
||||
user_id: User's Audiobookshelf ID (external_id from database)
|
||||
permissions: Dict with keys: allow_downloads, allow_live_tv, allow_camera_upload
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Get current user to preserve existing settings
|
||||
try:
|
||||
current = self.get_user(user_id)
|
||||
except Exception as exc:
|
||||
logging.error(f"ABS: Failed to get user {user_id} – {exc}")
|
||||
return False
|
||||
|
||||
# Get current permissions or create new ones
|
||||
current_perms = current.get("permissions", {}) or {}
|
||||
|
||||
# Update only the download permission (ABS doesn't have live TV or camera upload)
|
||||
current_perms["download"] = permissions.get("allow_downloads", False)
|
||||
|
||||
# Prepare payload with updated permissions
|
||||
payload = {"permissions": current_perms}
|
||||
|
||||
# Update user
|
||||
response = self.patch(f"{self.API_PREFIX}/users/{user_id}", json=payload)
|
||||
success = response.status_code == 200
|
||||
|
||||
if success:
|
||||
logging.info(
|
||||
f"Successfully updated permissions for Audiobookshelf user {user_id}"
|
||||
)
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to update Audiobookshelf permissions for {user_id}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
def update_user_libraries(
|
||||
self, user_id: str, library_names: list[str] | None
|
||||
) -> bool:
|
||||
"""Update user's library access on Audiobookshelf.
|
||||
|
||||
Args:
|
||||
user_id: User's Audiobookshelf ID (external_id from database)
|
||||
library_names: List of library names to grant access to, or None for all libraries
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Get current user to preserve existing settings
|
||||
try:
|
||||
current = self.get_user(user_id)
|
||||
except Exception as exc:
|
||||
logging.error(f"ABS: Failed to get user {user_id} – {exc}")
|
||||
return False
|
||||
|
||||
current_perms = current.get("permissions", {}) or {}
|
||||
|
||||
# Get library external IDs from database
|
||||
library_ids = []
|
||||
if library_names is not None:
|
||||
logging.info(f"AUDIOBOOKSHELF: Requested libraries: {library_names}")
|
||||
libraries = (
|
||||
Library.query.filter_by(server_id=self.server_id)
|
||||
.filter(Library.name.in_(library_names))
|
||||
.all()
|
||||
)
|
||||
|
||||
for lib in libraries:
|
||||
library_ids.append(lib.external_id)
|
||||
logging.info(f" ✓ {lib.name} -> {lib.external_id}")
|
||||
|
||||
# Check for missing libraries
|
||||
found_names = {lib.name for lib in libraries}
|
||||
missing = set(library_names) - found_names
|
||||
for name in missing:
|
||||
logging.warning(
|
||||
f" ✗ Library '{name}' not found in database (scan libraries to fix)"
|
||||
)
|
||||
|
||||
logging.info(f"AUDIOBOOKSHELF: Converted to library IDs: {library_ids}")
|
||||
else:
|
||||
# None means all libraries
|
||||
logging.info("AUDIOBOOKSHELF: Granting access to all libraries")
|
||||
|
||||
# Update permissions with library access settings
|
||||
current_perms["accessAllLibraries"] = library_names is None
|
||||
|
||||
# Prepare payload
|
||||
payload = {
|
||||
"permissions": current_perms,
|
||||
"librariesAccessible": library_ids if library_names is not None else [],
|
||||
}
|
||||
|
||||
# Update user
|
||||
response = self.patch(f"{self.API_PREFIX}/users/{user_id}", json=payload)
|
||||
success = response.status_code == 200
|
||||
|
||||
if success:
|
||||
logging.info(
|
||||
f"Successfully updated library access for Audiobookshelf user {user_id}"
|
||||
)
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to update Audiobookshelf library access for {user_id}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
def enable_user(self, user_id: str) -> bool:
|
||||
"""Enable a user account on Audiobookshelf.
|
||||
|
||||
|
||||
@@ -228,6 +228,48 @@ class MediaClient(ABC):
|
||||
def delete_user(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def update_user_permissions(
|
||||
self, _user_identifier: str, _permissions: dict[str, bool]
|
||||
) -> bool:
|
||||
"""Update user permissions on the media server.
|
||||
|
||||
Args:
|
||||
_user_identifier: User ID, email, or token depending on server type
|
||||
_permissions: Dict with keys: allow_downloads, allow_live_tv, allow_camera_upload
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
|
||||
Note:
|
||||
Default implementation returns False. Media servers that support
|
||||
permission updates should override this method.
|
||||
"""
|
||||
logging.warning(
|
||||
f"{self.__class__.__name__} does not support permission updates"
|
||||
)
|
||||
return False
|
||||
|
||||
def update_user_libraries(
|
||||
self, _user_identifier: str, _library_names: list[str] | None
|
||||
) -> bool:
|
||||
"""Update user's library access on the media server.
|
||||
|
||||
Args:
|
||||
_user_identifier: User ID, email, or token depending on server type
|
||||
_library_names: List of library names to grant access to, or None for all libraries
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
|
||||
Note:
|
||||
Default implementation returns False. Media servers that support
|
||||
library access updates should override this method.
|
||||
"""
|
||||
logging.warning(
|
||||
f"{self.__class__.__name__} does not support library access updates"
|
||||
)
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def get_user(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -164,6 +164,125 @@ class JellyfinClient(RestApiMixin):
|
||||
|
||||
return self.post(f"/Users/{jf_id}", json=current).json()
|
||||
|
||||
def update_user_permissions(
|
||||
self, user_id: str, permissions: dict[str, bool]
|
||||
) -> bool:
|
||||
"""Update user permissions on Jellyfin.
|
||||
|
||||
Args:
|
||||
user_id: User's Jellyfin ID (external_id from database)
|
||||
permissions: Dict with keys: allow_downloads, allow_live_tv, allow_camera_upload
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Get current policy
|
||||
raw_user = self.get(f"/Users/{user_id}").json()
|
||||
if not raw_user:
|
||||
logging.error(f"Jellyfin: User {user_id} not found")
|
||||
return False
|
||||
|
||||
current_policy = raw_user.get("Policy", {})
|
||||
|
||||
# Update permissions
|
||||
current_policy["EnableContentDownloading"] = permissions.get(
|
||||
"allow_downloads", False
|
||||
)
|
||||
current_policy["EnableLiveTvAccess"] = permissions.get(
|
||||
"allow_live_tv", False
|
||||
)
|
||||
# Jellyfin doesn't have a direct camera upload setting, but we keep the interface consistent
|
||||
# Store it in a comment field if needed in the future
|
||||
|
||||
# Update policy
|
||||
response = self.post(f"/Users/{user_id}/Policy", json=current_policy)
|
||||
success = response.status_code in {204, 200}
|
||||
|
||||
if success:
|
||||
logging.info(
|
||||
f"Successfully updated permissions for Jellyfin user {user_id}"
|
||||
)
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to update Jellyfin permissions for {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def update_user_libraries(
|
||||
self, user_id: str, library_names: list[str] | None
|
||||
) -> bool:
|
||||
"""Update user's library access on Jellyfin.
|
||||
|
||||
Args:
|
||||
user_id: User's Jellyfin ID (external_id from database)
|
||||
library_names: List of library names to grant access to, or None for all libraries
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Get current policy
|
||||
raw_user = self.get(f"/Users/{user_id}").json()
|
||||
if not raw_user:
|
||||
logging.error(f"Jellyfin: User {user_id} not found")
|
||||
return False
|
||||
|
||||
current_policy = raw_user.get("Policy", {})
|
||||
|
||||
# Get library external IDs from database
|
||||
folder_ids = []
|
||||
if library_names is not None:
|
||||
logging.info(f"JELLYFIN: Requested libraries: {library_names}")
|
||||
libraries = (
|
||||
Library.query.filter_by(server_id=self.server_id)
|
||||
.filter(Library.name.in_(library_names))
|
||||
.all()
|
||||
)
|
||||
|
||||
for lib in libraries:
|
||||
folder_ids.append(lib.external_id)
|
||||
logging.info(f" ✓ {lib.name} -> {lib.external_id}")
|
||||
|
||||
# Check for missing libraries
|
||||
found_names = {lib.name for lib in libraries}
|
||||
missing = set(library_names) - found_names
|
||||
for name in missing:
|
||||
logging.warning(
|
||||
f" ✗ Library '{name}' not found in database (scan libraries to fix)"
|
||||
)
|
||||
|
||||
logging.info(f"JELLYFIN: Converted to folder IDs: {folder_ids}")
|
||||
else:
|
||||
# None means all libraries - get all enabled libraries for this server
|
||||
libraries = Library.query.filter_by(
|
||||
server_id=self.server_id, enabled=True
|
||||
).all()
|
||||
folder_ids = [lib.external_id for lib in libraries]
|
||||
logging.info(f"JELLYFIN: Using all library IDs: {folder_ids}")
|
||||
|
||||
# Update policy with library access
|
||||
current_policy["EnableAllFolders"] = library_names is None
|
||||
current_policy["EnabledFolders"] = (
|
||||
folder_ids if library_names is not None else []
|
||||
)
|
||||
|
||||
# Update policy
|
||||
response = self.post(f"/Users/{user_id}/Policy", json=current_policy)
|
||||
success = response.status_code in {204, 200}
|
||||
|
||||
if success:
|
||||
logging.info(
|
||||
f"Successfully updated library access for Jellyfin user {user_id}"
|
||||
)
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to update Jellyfin library access for {user_id}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
def enable_user(self, user_id: str) -> bool:
|
||||
"""Enable a user account on Jellyfin.
|
||||
|
||||
|
||||
@@ -14,73 +14,14 @@ from app.services.media.service import get_client_for_media_server
|
||||
from app.services.notifications import notify
|
||||
|
||||
from .client_base import MediaClient, register_media_client
|
||||
from .plex_custom import accept_invite_v2, update_shared_server
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.services.media.user_details import MediaUserDetails
|
||||
|
||||
|
||||
def _accept_invite_v2(self: MyPlexAccount, user):
|
||||
"""Accept a pending server share via the v2 API."""
|
||||
base = "https://clients.plex.tv"
|
||||
|
||||
params = {
|
||||
k: v
|
||||
for k, v in self._session.headers.items()
|
||||
if k.startswith("X-Plex-") and k != "X-Plex-Provides"
|
||||
}
|
||||
|
||||
defaults = {
|
||||
"X-Plex-Product": "Wizarr",
|
||||
"X-Plex-Version": "1.0",
|
||||
"X-Plex-Client-Identifier": "wizarr-client",
|
||||
"X-Plex-Platform": "Python",
|
||||
"X-Plex-Platform-Version": "3",
|
||||
"X-Plex-Device": "Server",
|
||||
"X-Plex-Device-Name": "Wizarr",
|
||||
"X-Plex-Model": "server",
|
||||
"X-Plex-Device-Screen-Resolution": "1920x1080",
|
||||
"X-Plex-Features": "external-media,indirect-media,hub-style-list",
|
||||
"X-Plex-Language": "en",
|
||||
}
|
||||
|
||||
for key, value in defaults.items():
|
||||
params.setdefault(key, value)
|
||||
|
||||
params["X-Plex-Token"] = self.authToken
|
||||
hdrs = {"Accept": "application/json"}
|
||||
|
||||
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()
|
||||
|
||||
def _matches(inv):
|
||||
o = inv.get("owner", {})
|
||||
return user in (
|
||||
o.get("username"),
|
||||
o.get("email"),
|
||||
o.get("title"),
|
||||
o.get("friendlyName"),
|
||||
)
|
||||
|
||||
try:
|
||||
inv = next(i for i in invites if _matches(i))
|
||||
except StopIteration as exc:
|
||||
raise ValueError(f"No pending invite from '{user}' found") from exc
|
||||
|
||||
shared_servers = inv.get("sharedServers")
|
||||
if not shared_servers:
|
||||
raise ValueError("Invite structure missing 'sharedServers' list")
|
||||
|
||||
invite_id = shared_servers[0]["id"]
|
||||
|
||||
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
|
||||
|
||||
|
||||
MyPlexAccount.acceptInvite = _accept_invite_v2 # type: ignore[assignment]
|
||||
# Patch PlexAPI's acceptInvite method with our custom v2 implementation
|
||||
MyPlexAccount.acceptInvite = accept_invite_v2 # type: ignore[assignment]
|
||||
|
||||
|
||||
def extract_plex_error_message(exception) -> str:
|
||||
@@ -170,7 +111,17 @@ class PlexClient(MediaClient):
|
||||
return self._admin
|
||||
|
||||
def libraries(self) -> dict[str, str]:
|
||||
return {lib.title: lib.title for lib in self.server.library.sections()}
|
||||
"""Get all libraries with their global IDs.
|
||||
|
||||
Returns:
|
||||
dict: Mapping of {global_id: library_name} for database storage
|
||||
where global_id is used as external_id in the Library model
|
||||
"""
|
||||
# Get global library IDs from Plex API
|
||||
library_map = self._get_all_library_global_ids()
|
||||
|
||||
# Return {global_id: name} so external_id stores the global ID
|
||||
return {str(global_id): name for name, global_id in library_map.items()}
|
||||
|
||||
# ─── Helper Methods ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -548,6 +499,326 @@ class PlexClient(MediaClient):
|
||||
allowCameraUpload=bool(form.get("allowCameraUpload")),
|
||||
)
|
||||
|
||||
def _get_all_library_global_ids(self) -> dict[str, int]:
|
||||
"""Get mapping of all server libraries (name -> global ID).
|
||||
|
||||
This fetches library information from the Plex server's own libraries endpoint,
|
||||
which includes the global library section IDs needed for the sharing API.
|
||||
|
||||
Returns:
|
||||
dict: Mapping of library title to global ID {title: id}
|
||||
"""
|
||||
try:
|
||||
base = "https://plex.tv"
|
||||
url = f"{base}/api/v2/servers/{self.server.machineIdentifier}"
|
||||
|
||||
params = {
|
||||
"X-Plex-Product": "Wizarr",
|
||||
"X-Plex-Version": "1.0",
|
||||
"X-Plex-Client-Identifier": self.admin.uuid,
|
||||
"X-Plex-Token": self.admin.authToken,
|
||||
"X-Plex-Platform": "Web",
|
||||
"X-Plex-Features": "external-media,indirect-media,hub-style-list",
|
||||
"X-Plex-Language": "en",
|
||||
}
|
||||
|
||||
headers = {"Accept": "application/json"}
|
||||
|
||||
resp = self.admin._session.get(url, params=params, headers=headers)
|
||||
resp.raise_for_status()
|
||||
server_data = resp.json()
|
||||
|
||||
# Extract libraries from server data (Plex uses 'librarySections' key)
|
||||
libraries = server_data.get("librarySections", [])
|
||||
|
||||
library_map = {}
|
||||
|
||||
for lib in libraries:
|
||||
title = lib.get("title")
|
||||
lib_id = lib.get("id")
|
||||
if title and lib_id:
|
||||
library_map[title] = lib_id
|
||||
|
||||
logging.debug(f"Found {len(library_map)} libraries with global IDs")
|
||||
return library_map
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to get library global IDs: {e}", exc_info=True)
|
||||
return {}
|
||||
|
||||
def _get_share_data(self, email: str) -> dict | None:
|
||||
"""Get the complete share data for a user.
|
||||
|
||||
Returns the full share object which includes the shared_server ID
|
||||
and the library mappings with their global IDs.
|
||||
|
||||
Args:
|
||||
email: User's email address
|
||||
|
||||
Returns:
|
||||
dict: The share data, or None if not found
|
||||
"""
|
||||
try:
|
||||
# First, try to get the Plex user object to find their ID
|
||||
plex_user_id = None
|
||||
try:
|
||||
plex_user = self.admin.user(email)
|
||||
if plex_user:
|
||||
plex_user_id = getattr(plex_user, "id", None)
|
||||
except Exception as exc:
|
||||
# If getting by email fails, might not exist or different identifier
|
||||
logging.debug(f"Could not get Plex user by email {email}: {exc}")
|
||||
|
||||
# GET the list of owned/accepted shares
|
||||
base = "https://clients.plex.tv"
|
||||
url = f"{base}/api/v2/shared_servers/owned/accepted"
|
||||
|
||||
params = {
|
||||
"X-Plex-Product": "Wizarr",
|
||||
"X-Plex-Version": "1.0",
|
||||
"X-Plex-Client-Identifier": self.admin.uuid,
|
||||
"X-Plex-Token": self.admin.authToken,
|
||||
"X-Plex-Platform": "Web",
|
||||
"X-Plex-Platform-Version": "1.0",
|
||||
"X-Plex-Features": "external-media,indirect-media,hub-style-list",
|
||||
"X-Plex-Language": "en",
|
||||
}
|
||||
|
||||
headers = {"Accept": "application/json"}
|
||||
|
||||
resp = self.admin._session.get(url, params=params, headers=headers)
|
||||
resp.raise_for_status()
|
||||
shared_servers = resp.json()
|
||||
|
||||
# Find the share matching this server and user
|
||||
for share in shared_servers:
|
||||
# Check if this share is for our server
|
||||
if share.get("machineIdentifier") != self.server.machineIdentifier:
|
||||
continue
|
||||
|
||||
# Try matching by Plex user ID first (most reliable)
|
||||
if plex_user_id and share.get("invitedId") == plex_user_id:
|
||||
return share
|
||||
|
||||
# Fallback: Try matching by email/username
|
||||
invited = share.get("invited", {})
|
||||
invited_email = (
|
||||
invited.get("email")
|
||||
or invited.get("username")
|
||||
or share.get("invitedEmail")
|
||||
)
|
||||
|
||||
if invited_email and invited_email.lower() == email.lower():
|
||||
return share
|
||||
|
||||
logging.warning(
|
||||
f"No shared_server found for {email} on server {self.server.friendlyName}"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to get share data for {email}: {e}")
|
||||
return None
|
||||
|
||||
def _get_shared_server_id(self, email: str) -> int | None:
|
||||
"""Get the shared_server ID from the admin's perspective."""
|
||||
share = self._get_share_data(email)
|
||||
return share.get("id") if share else None
|
||||
|
||||
def _get_current_plex_state(self, email: str) -> tuple[dict, list | None]:
|
||||
"""Get current permissions and library access for a Plex user.
|
||||
|
||||
Args:
|
||||
email: User's email address
|
||||
|
||||
Returns:
|
||||
Tuple of (permissions_dict, sections_list)
|
||||
permissions_dict has keys: allow_downloads, allow_live_tv, allow_camera_upload
|
||||
sections_list is either None or a list of LibrarySection objects
|
||||
"""
|
||||
plex_user = self.admin.user(email)
|
||||
if not plex_user:
|
||||
raise ValueError(f"Plex user not found: {email}")
|
||||
|
||||
# Extract current permissions
|
||||
permissions = self._extract_plex_permissions(plex_user)
|
||||
|
||||
# Find this server's share for the user
|
||||
matching_share = next(
|
||||
(
|
||||
s
|
||||
for s in getattr(plex_user, "servers", []) or []
|
||||
if getattr(s, "machineIdentifier", None)
|
||||
== self.server.machineIdentifier
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
# Get current sections (libraries) for this user
|
||||
sections = None
|
||||
if matching_share:
|
||||
library_names = self._extract_library_names_from_share(matching_share)
|
||||
# Convert library names to section objects
|
||||
if library_names:
|
||||
all_sections = self.server.library.sections()
|
||||
sections = [s for s in all_sections if s.title in library_names]
|
||||
|
||||
return permissions, sections
|
||||
|
||||
def update_user_permissions(self, email: str, permissions: dict[str, bool]) -> bool:
|
||||
"""Update user permissions on Plex using the shared_servers API.
|
||||
|
||||
Args:
|
||||
email: User's email address
|
||||
permissions: Dict with keys: allow_downloads, allow_live_tv, allow_camera_upload
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Get the shared_server ID
|
||||
shared_server_id = self._get_shared_server_id(email)
|
||||
if not shared_server_id:
|
||||
logging.error(f"Could not find shared_server ID for {email}")
|
||||
return False
|
||||
|
||||
# Get current library section IDs to preserve them
|
||||
# Use share data to get the global library IDs
|
||||
share = self._get_share_data(email)
|
||||
if not share:
|
||||
logging.error(f"Could not get share data for {email}")
|
||||
return False
|
||||
|
||||
section_ids = [lib["id"] for lib in share.get("libraries", [])]
|
||||
|
||||
# Build settings with new permissions
|
||||
settings = {
|
||||
"allowSync": permissions.get("allow_downloads", False),
|
||||
"allowChannels": permissions.get("allow_live_tv", False),
|
||||
"allowCameraUpload": permissions.get("allow_camera_upload", False),
|
||||
"filterMovies": "",
|
||||
"filterMusic": "",
|
||||
"filterPhotos": None,
|
||||
"filterTelevision": "",
|
||||
"filterAll": None,
|
||||
"allowSubtitleAdmin": False,
|
||||
"allowTuners": 0,
|
||||
}
|
||||
|
||||
# Call the custom API method
|
||||
success = update_shared_server(
|
||||
self.admin, shared_server_id, settings, section_ids
|
||||
)
|
||||
|
||||
if success:
|
||||
logging.info(
|
||||
f"Successfully updated permissions for {email} via shared_servers API"
|
||||
)
|
||||
|
||||
return success
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to update permissions for {email}: {e}")
|
||||
return False
|
||||
|
||||
def update_user_libraries(
|
||||
self, email: str, library_names: list[str] | None
|
||||
) -> bool:
|
||||
"""Update user's library access on Plex using the shared_servers API.
|
||||
|
||||
Args:
|
||||
email: User's email address
|
||||
library_names: List of library names to grant access to, or None for all libraries
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Get the shared_server ID
|
||||
shared_server_id = self._get_shared_server_id(email)
|
||||
if not shared_server_id:
|
||||
logging.error(f"Could not find shared_server ID for {email}")
|
||||
return False
|
||||
|
||||
# Get current permissions to preserve them
|
||||
current_perms, _ = self._get_current_plex_state(email)
|
||||
|
||||
# Get the share data to access library ID mappings
|
||||
share = self._get_share_data(email)
|
||||
if not share:
|
||||
logging.error(f"Could not get share data for {email}")
|
||||
return False
|
||||
|
||||
# Log current share state
|
||||
current_libs = share.get("libraries", [])
|
||||
logging.info(
|
||||
f"Current libraries in share: {[lib['title'] for lib in current_libs]}"
|
||||
)
|
||||
logging.info(
|
||||
f"Current library IDs in share: {[lib['id'] for lib in current_libs]}"
|
||||
)
|
||||
|
||||
# Get library global IDs from database (external_id stores the global ID)
|
||||
# This assumes libraries have been scanned and stored correctly
|
||||
from app.models import Library
|
||||
|
||||
section_ids = []
|
||||
if library_names is not None:
|
||||
logging.info(f"Requested libraries: {library_names}")
|
||||
libraries = (
|
||||
Library.query.filter_by(server_id=self.server_id)
|
||||
.filter(Library.name.in_(library_names))
|
||||
.all()
|
||||
)
|
||||
|
||||
for lib in libraries:
|
||||
section_ids.append(int(lib.external_id))
|
||||
logging.info(f" ✓ {lib.name} -> {lib.external_id}")
|
||||
|
||||
# Check for missing libraries
|
||||
found_names = {lib.name for lib in libraries}
|
||||
missing = set(library_names) - found_names
|
||||
for name in missing:
|
||||
logging.warning(
|
||||
f" ✗ Library '{name}' not found in database (scan libraries to fix)"
|
||||
)
|
||||
|
||||
logging.info(f"Converted to section IDs: {section_ids}")
|
||||
else:
|
||||
# None means all libraries - get all enabled libraries for this server
|
||||
libraries = Library.query.filter_by(
|
||||
server_id=self.server_id, enabled=True
|
||||
).all()
|
||||
section_ids = [int(lib.external_id) for lib in libraries]
|
||||
logging.info(f"Using all library IDs: {section_ids}")
|
||||
|
||||
# Build settings with preserved permissions
|
||||
settings = {
|
||||
"allowSync": current_perms["allow_downloads"],
|
||||
"allowChannels": current_perms["allow_live_tv"],
|
||||
"allowCameraUpload": current_perms["allow_camera_upload"],
|
||||
"filterMovies": "",
|
||||
"filterMusic": "",
|
||||
"filterPhotos": None,
|
||||
"filterTelevision": "",
|
||||
"filterAll": None,
|
||||
"allowSubtitleAdmin": False,
|
||||
"allowTuners": 0,
|
||||
}
|
||||
|
||||
# Call the custom API method
|
||||
success = update_shared_server(
|
||||
self.admin, shared_server_id, settings, section_ids
|
||||
)
|
||||
|
||||
if success:
|
||||
logging.info(
|
||||
f"Successfully updated library access for {email} via shared_servers API"
|
||||
)
|
||||
|
||||
return success
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to update library access for {email}: {e}")
|
||||
return False
|
||||
|
||||
def enable_user(self, _user_id: str) -> bool:
|
||||
"""Enable a user account on Plex.
|
||||
|
||||
|
||||
145
app/services/media/plex_custom.py
Normal file
145
app/services/media/plex_custom.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Custom Plex API methods that extend or replace PlexAPI library functionality.
|
||||
|
||||
This module contains direct HTTP calls to Plex APIs that are not properly supported
|
||||
by the python-plexapi library, or where the library's implementation is outdated.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
def accept_invite_v2(account, user: str):
|
||||
"""Accept a Plex server invitation using the v2 shared_servers API.
|
||||
|
||||
This replaces the broken acceptInvite() method in PlexAPI which uses
|
||||
an outdated API endpoint.
|
||||
|
||||
Args:
|
||||
account: MyPlexAccount instance
|
||||
user: Username, email, or friendly name of the inviting user
|
||||
|
||||
Returns:
|
||||
Response object from the API call
|
||||
|
||||
Raises:
|
||||
ValueError: If no pending invite is found or invite structure is invalid
|
||||
"""
|
||||
base = "https://clients.plex.tv"
|
||||
|
||||
# Build default Plex client headers
|
||||
params = {
|
||||
"X-Plex-Product": "Wizarr",
|
||||
"X-Plex-Version": "1.0",
|
||||
"X-Plex-Client-Identifier": account.uuid,
|
||||
"X-Plex-Platform": "Web",
|
||||
"X-Plex-Platform-Version": "1.0",
|
||||
"X-Plex-Device": "Web",
|
||||
"X-Plex-Device-Name": "Wizarr",
|
||||
"X-Plex-Model": "bundled",
|
||||
"X-Plex-Features": "external-media,indirect-media,hub-style-list",
|
||||
"X-Plex-Language": "en",
|
||||
}
|
||||
|
||||
params["X-Plex-Token"] = account.authToken
|
||||
hdrs = {"Accept": "application/json"}
|
||||
|
||||
# Get pending invites
|
||||
url_list = f"{base}/api/v2/shared_servers/invites/received/pending"
|
||||
resp = account._session.get(url_list, params=params, headers=hdrs)
|
||||
resp.raise_for_status()
|
||||
invites = resp.json()
|
||||
|
||||
# Find matching invite
|
||||
def _matches(inv):
|
||||
o = inv.get("owner", {})
|
||||
return user in (
|
||||
o.get("username"),
|
||||
o.get("email"),
|
||||
o.get("title"),
|
||||
o.get("friendlyName"),
|
||||
)
|
||||
|
||||
try:
|
||||
inv = next(i for i in invites if _matches(i))
|
||||
except StopIteration as exc:
|
||||
raise ValueError(f"No pending invite from '{user}' found") from exc
|
||||
|
||||
shared_servers = inv.get("sharedServers")
|
||||
if not shared_servers:
|
||||
raise ValueError("Invite structure missing 'sharedServers' list")
|
||||
|
||||
invite_id = shared_servers[0]["id"]
|
||||
|
||||
# Accept the invite
|
||||
url_accept = f"{base}/api/v2/shared_servers/{invite_id}/accept"
|
||||
resp = account._session.post(url_accept, params=params, headers=hdrs)
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
|
||||
|
||||
def update_shared_server(
|
||||
account,
|
||||
shared_server_id: int,
|
||||
settings: dict,
|
||||
section_ids: list[int],
|
||||
) -> bool:
|
||||
"""Update a shared server's permissions and library access.
|
||||
|
||||
This uses the modern /shared_servers/ API that Plex Web uses, which is more
|
||||
reliable than the PlexAPI library's updateFriend() method.
|
||||
|
||||
Args:
|
||||
account: MyPlexAccount instance
|
||||
shared_server_id: The shared server ID (not the same as sharing_id)
|
||||
settings: Dict with permission flags:
|
||||
- allowSync: bool
|
||||
- allowChannels: bool
|
||||
- allowCameraUpload: bool
|
||||
- filterMovies: str (usually "")
|
||||
- filterMusic: str (usually "")
|
||||
- filterPhotos: str | None
|
||||
- filterTelevision: str (usually "")
|
||||
- filterAll: str | None
|
||||
- allowSubtitleAdmin: bool (usually False)
|
||||
- allowTuners: int (usually 0)
|
||||
section_ids: List of numeric library section IDs to grant access to
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
base = "https://clients.plex.tv"
|
||||
url = f"{base}/api/v2/shared_servers/{shared_server_id}"
|
||||
|
||||
# Build request payload
|
||||
payload = {
|
||||
"settings": settings,
|
||||
"librarySectionIds": section_ids,
|
||||
}
|
||||
|
||||
# Build headers with Plex client info
|
||||
params = {
|
||||
"X-Plex-Product": "Wizarr",
|
||||
"X-Plex-Version": "1.0",
|
||||
"X-Plex-Client-Identifier": account.uuid,
|
||||
"X-Plex-Token": account.authToken,
|
||||
"X-Plex-Platform": "Web",
|
||||
"X-Plex-Platform-Version": "1.0",
|
||||
"X-Plex-Features": "external-media,indirect-media,hub-style-list",
|
||||
"X-Plex-Language": "en",
|
||||
}
|
||||
|
||||
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
||||
|
||||
logging.info(f"Updating shared_server {shared_server_id}")
|
||||
logging.info(f"Sending payload: {payload}")
|
||||
|
||||
resp = account._session.post(url, params=params, headers=headers, json=payload)
|
||||
resp.raise_for_status()
|
||||
|
||||
logging.info(f"Response status: {resp.status_code}")
|
||||
logging.info(f"Response body: {resp.text[:500] if resp.text else 'empty'}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to update shared_server {shared_server_id}: {e}")
|
||||
return False
|
||||
2
app/static/js/tiny-mde.min.js
vendored
2
app/static/js/tiny-mde.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,141 +1,384 @@
|
||||
<div class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all w-full max-w-2xl dark:bg-gray-800"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title">
|
||||
<div class="bg-white px-4 pb-4 pt-5 sm:p-6 sm:pb-4 dark:bg-gray-800">
|
||||
<div class="flex justify-between items-center pb-3">
|
||||
<h2 class="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
|
||||
{{ _("Edit expiry") }} – {{ user.username or user.email }}
|
||||
</h2>
|
||||
<button type="button"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
onclick="document.getElementById('modal').classList.add('hidden')">
|
||||
<svg class="w-3 h-3"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
|
||||
</svg>
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
</div>
|
||||
{% if user.identity_id and related_users and related_users|length > 1 %}
|
||||
<!-- Multi-server user management -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">{{ _("Server-specific Expiry Management") }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{{ _("This user exists on multiple servers. You can set different expiry dates for each server.") }}
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
{% for related_user in related_users %}
|
||||
<div class="border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
{% if related_user.server %}
|
||||
{{ related_user.server.server_type|server_name_tag(related_user.server.name) }}
|
||||
{% else %}
|
||||
{{ 'local'|server_type_tag }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">ID: {{ related_user.id }}</span>
|
||||
</div>
|
||||
<form hx-post="{{ url_for('admin.user_detail', db_id=related_user.id) }}"
|
||||
class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<label for="expires_{{ related_user.id }}"
|
||||
class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ _("Expiry date") }}
|
||||
</label>
|
||||
<input id="expires_{{ related_user.id }}"
|
||||
name="expires"
|
||||
type="datetime-local"
|
||||
value="{{ related_user.expires|default('', true) and related_user.expires.strftime('%Y-%m-%dT%H:%M') or '' }}"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-3 py-2.5 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary_hover focus:ring-4 focus:outline-none focus:ring-primary_focus dark:bg-primary dark:hover:bg-primary_hover dark:focus:ring-primary_focus">
|
||||
{{ _("Update") }}
|
||||
<div
|
||||
class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all w-full max-w-2xl dark:bg-gray-800"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
x-data="{ activeTab: 'tab-{{ related_users[0].id }}' }"
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-800">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center px-4 pt-5 sm:px-6 sm:pt-6 pb-3">
|
||||
<h2
|
||||
id="modal-title"
|
||||
class="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white"
|
||||
>
|
||||
{{ _("Edit User") }} – {{ user.username or user.email }}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
onclick="document.getElementById('modal').classList.add('hidden')"
|
||||
aria-label="{{ _('Close modal') }}"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 14 14"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">{{ _("Close modal") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 px-4 sm:px-6">
|
||||
<nav class="flex -mb-px space-x-2 overflow-x-auto" aria-label="{{ _('Tabs') }}" role="tablist">
|
||||
{% for related_user in related_users %}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'tab-{{ related_user.id }}'"
|
||||
@click="activeTab = 'tab-{{ related_user.id }}'"
|
||||
:class="activeTab === 'tab-{{ related_user.id }}'
|
||||
? 'border-primary text-primary dark:border-primary dark:text-primary'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
|
||||
class="whitespace-nowrap py-3 px-4 border-b-2 font-medium text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition-colors"
|
||||
>
|
||||
{% if related_user.server %}
|
||||
{{ related_user.server.name }}
|
||||
{% else %}
|
||||
{{ _("Local") }}
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{% if related_user.expires %}
|
||||
{{ _("Current expiry") }}: {{ related_user.expires|human_date }}
|
||||
{% else %}
|
||||
{{ _("No expiry set") }}
|
||||
{% endfor %}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'tab-notes'"
|
||||
@click="activeTab = 'tab-notes'"
|
||||
:class="activeTab === 'tab-notes'
|
||||
? 'border-primary text-primary dark:border-primary dark:text-primary'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
|
||||
class="whitespace-nowrap py-3 px-4 border-b-2 font-medium text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition-colors"
|
||||
>
|
||||
{{ _("Notes") }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="px-4 pb-4 pt-4 sm:p-6 sm:pt-4">
|
||||
{% for related_user in related_users %}
|
||||
<div
|
||||
x-show="activeTab === 'tab-{{ related_user.id }}'"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
role="tabpanel"
|
||||
:aria-hidden="activeTab !== 'tab-{{ related_user.id }}'"
|
||||
>
|
||||
<!-- Server Badge (for multi-server setups) -->
|
||||
{% if related_users|length > 1 %}
|
||||
<div class="flex items-center justify-between mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center">
|
||||
{% if related_user.server %}
|
||||
{{ related_user.server.server_type|server_name_tag(related_user.server.name) }}
|
||||
{% else %}
|
||||
{{ 'local'|server_type_tag }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
ID: {{ related_user.id }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Permission Management Section (only for supported servers) -->
|
||||
{% if related_user.server and related_user.server.server_type in ['plex', 'jellyfin', 'emby', 'audiobookshelf'] %}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{{ _("Permission Management") }}
|
||||
</h3>
|
||||
|
||||
<!-- Permission Toggles -->
|
||||
<div class="space-y-3 bg-gray-50 dark:bg-gray-900 rounded-lg p-4"
|
||||
x-data="{
|
||||
downloading: {{ (related_user.allow_downloads if related_user.allow_downloads is not none else false)|tojson }},
|
||||
liveTv: {{ (related_user.allow_live_tv if related_user.allow_live_tv is not none else false)|tojson }},
|
||||
cameraUpload: {{ (related_user.allow_camera_upload if related_user.allow_camera_upload is not none else false)|tojson }},
|
||||
updating: {
|
||||
downloads: false,
|
||||
liveTv: false,
|
||||
camera: false
|
||||
},
|
||||
togglePermission(type, formRef) {
|
||||
// Store the current value in case we need to roll back
|
||||
const originalValue = this[type];
|
||||
|
||||
// Set updating state
|
||||
this.updating[type === 'downloading' ? 'downloads' : (type === 'liveTv' ? 'liveTv' : 'camera')] = true;
|
||||
|
||||
// Optimistically update UI
|
||||
this[type] = !this[type];
|
||||
|
||||
// Trigger the form submission
|
||||
this.$nextTick(() => htmx.trigger(formRef, 'submit'));
|
||||
},
|
||||
showError(message) {
|
||||
alert('Error: ' + message);
|
||||
}
|
||||
}">
|
||||
|
||||
<!-- Downloads Permission (All servers support this) -->
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ _("Allow Downloads") }}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="togglePermission('downloading', $refs.downloadForm)"
|
||||
:disabled="updating.downloads"
|
||||
:class="[
|
||||
downloading ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700',
|
||||
updating.downloads ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
|
||||
]"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
aria-label="{{ _('Toggle downloads permission') }}"
|
||||
>
|
||||
<span
|
||||
:class="downloading ? 'translate-x-5' : 'translate-x-0'"
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
></span>
|
||||
</button>
|
||||
<form x-ref="downloadForm"
|
||||
method="post"
|
||||
action="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
|
||||
hx-post="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
|
||||
hx-swap="none"
|
||||
@htmx:after-request="updating.downloads = false"
|
||||
@htmx:response-error="downloading = !downloading; updating.downloads = false; showError($event.detail.xhr.responseText || 'Failed to update permission')"
|
||||
class="hidden">
|
||||
<input type="hidden" name="permission_type" value="allow_downloads">
|
||||
<input type="hidden" name="enabled" :value="downloading ? 'true' : 'false'">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Live TV Permission (Plex, Jellyfin, Emby only) -->
|
||||
{% if related_user.server and related_user.server.server_type in ['plex', 'jellyfin', 'emby'] %}
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ _("Allow Live TV") }}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="togglePermission('liveTv', $refs.liveTvForm)"
|
||||
:disabled="updating.liveTv"
|
||||
:class="[
|
||||
liveTv ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700',
|
||||
updating.liveTv ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
|
||||
]"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
aria-label="{{ _('Toggle live TV permission') }}"
|
||||
>
|
||||
<span
|
||||
:class="liveTv ? 'translate-x-5' : 'translate-x-0'"
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
></span>
|
||||
</button>
|
||||
<form x-ref="liveTvForm"
|
||||
method="post"
|
||||
action="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
|
||||
hx-post="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
|
||||
hx-swap="none"
|
||||
@htmx:after-request="updating.liveTv = false"
|
||||
@htmx:response-error="liveTv = !liveTv; updating.liveTv = false; showError($event.detail.xhr.responseText || 'Failed to update permission')"
|
||||
class="hidden">
|
||||
<input type="hidden" name="permission_type" value="allow_live_tv">
|
||||
<input type="hidden" name="enabled" :value="liveTv ? 'true' : 'false'">
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Camera Upload Permission (Plex only) -->
|
||||
{% if related_user.server and related_user.server.server_type == 'plex' %}
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ _("Allow Camera Upload") }}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="togglePermission('cameraUpload', $refs.cameraForm)"
|
||||
:disabled="updating.camera"
|
||||
:class="[
|
||||
cameraUpload ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700',
|
||||
updating.camera ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
|
||||
]"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
aria-label="{{ _('Toggle camera upload permission') }}"
|
||||
>
|
||||
<span
|
||||
:class="cameraUpload ? 'translate-x-5' : 'translate-x-0'"
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
></span>
|
||||
</button>
|
||||
<form x-ref="cameraForm"
|
||||
method="post"
|
||||
action="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
|
||||
hx-post="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
|
||||
hx-swap="none"
|
||||
@htmx:after-request="updating.camera = false"
|
||||
@htmx:response-error="cameraUpload = !cameraUpload; updating.camera = false; showError($event.detail.xhr.responseText || 'Failed to update permission')"
|
||||
class="hidden">
|
||||
<input type="hidden" name="permission_type" value="allow_camera_upload">
|
||||
<input type="hidden" name="enabled" :value="cameraUpload ? 'true' : 'false'">
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Library Access Section -->
|
||||
{% set lib_data = user_libraries_map.get(related_user.id, {}) %}
|
||||
{% set libraries = lib_data.get('libraries', []) %}
|
||||
{% set accessible = lib_data.get('accessible_libraries', []) %}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{{ _("Library Access") }}
|
||||
</h3>
|
||||
|
||||
{% if libraries|length == 0 %}
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>{{ _("No libraries available") }}</p>
|
||||
<p class="text-xs mt-1">{{ _("Scan libraries from the invitation page to enable library restrictions") }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="relative" x-data="{ updating: false }">
|
||||
<form id="library-form-{{ related_user.id }}"
|
||||
@htmx:before-request="updating = true"
|
||||
@htmx:after-request="updating = false"
|
||||
:class="updating ? 'opacity-60 pointer-events-none' : ''"
|
||||
class="transition-opacity duration-200">
|
||||
<ul class="h-48 px-3 py-2 overflow-y-auto text-sm text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-900">
|
||||
{% for library in libraries %}
|
||||
<li>
|
||||
<label class="flex items-center ps-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer py-2"
|
||||
:class="updating ? 'cursor-not-allowed' : 'cursor-pointer'">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="library_ids[]"
|
||||
value="{{ library.id }}"
|
||||
{% if accessible|length == 0 or library.name in accessible %}checked{% endif %}
|
||||
hx-post="{{ url_for('admin.update_user_libraries', db_id=related_user.id) }}"
|
||||
hx-trigger="change"
|
||||
hx-include="#library-form-{{ related_user.id }}"
|
||||
hx-swap="none"
|
||||
:disabled="updating"
|
||||
class="w-4 h-4 text-primary bg-gray-100 border-gray-300 rounded focus:ring-primary dark:focus:ring-primary dark:ring-offset-gray-900 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<span class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
{{ library.name }}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</form>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ _("Click to toggle library access. Updates save automatically.") }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Expiry Management Section -->
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{{ _("Expiry Management") }}
|
||||
</h3>
|
||||
<form
|
||||
hx-post="{{ url_for('admin.user_detail', db_id=related_user.id) }}"
|
||||
class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<label
|
||||
for="expires_{{ related_user.id }}"
|
||||
class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ _("Expiry date") }}
|
||||
</label>
|
||||
<input
|
||||
id="expires_{{ related_user.id }}"
|
||||
name="expires"
|
||||
type="datetime-local"
|
||||
value="{{ related_user.expires|default('', true) and related_user.expires.strftime('%Y-%m-%dT%H:%M') or '' }}"
|
||||
class="bg-white border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-800 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center px-4 py-2.5 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary_hover focus:ring-4 focus:outline-none focus:ring-primary_focus dark:bg-primary dark:hover:bg-primary_hover dark:focus:ring-primary_focus"
|
||||
>
|
||||
{{ _("Update") }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{% if related_user.expires %}
|
||||
{{ _("Current expiry") }}: {{ related_user.expires|human_date }}
|
||||
{% else %}
|
||||
{{ _("No expiry set") }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<!-- Global Notes section for multi-server users -->
|
||||
<div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-600">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">{{ _("Notes") }}</h4>
|
||||
<form hx-post="{{ url_for('admin.user_detail', db_id=user.id) }}"
|
||||
class="space-y-3">
|
||||
<div>
|
||||
<textarea id="notes"
|
||||
name="notes"
|
||||
rows="3"
|
||||
placeholder="{{ _('Add notes about this user...') }}"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">{{ user.notes|default('', true) }}</textarea>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Notes Tab Content -->
|
||||
<div
|
||||
x-show="activeTab === 'tab-notes'"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
role="tabpanel"
|
||||
:aria-hidden="activeTab !== 'tab-notes'"
|
||||
>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{{ _("Notes") }}
|
||||
</h3>
|
||||
<form
|
||||
hx-post="{{ url_for('admin.user_detail', db_id=user.id) }}"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label for="notes" class="sr-only">{{ _("User notes") }}</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows="6"
|
||||
placeholder="{{ _('Add notes about this user...') }}"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-3 dark:bg-gray-900 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||
>{{ user.notes|default('', true) }}</textarea>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center px-4 py-2.5 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary_hover focus:ring-4 focus:outline-none focus:ring-primary_focus dark:bg-primary dark:hover:bg-primary_hover dark:focus:ring-primary_focus"
|
||||
>
|
||||
{{ _("Update Notes") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-3 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary_hover focus:ring-4 focus:outline-none focus:ring-primary_focus dark:bg-primary dark:hover:bg-primary_hover dark:focus:ring-primary_focus">
|
||||
{{ _("Update Notes") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Single server user management -->
|
||||
<form hx-post="{{ url_for('admin.user_detail', db_id=user.id) }}"
|
||||
class="space-y-4 md:space-y-6">
|
||||
<div>
|
||||
<label for="expires"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ _("Expiry date (leave blank for never)") }}
|
||||
</label>
|
||||
<input id="expires"
|
||||
name="expires"
|
||||
type="datetime-local"
|
||||
value="{{ user.expires|default('', true) and user.expires.strftime('%Y-%m-%dT%H:%M') or '' }}"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label for="notes"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">{{ _("Notes") }}</label>
|
||||
<textarea id="notes"
|
||||
name="notes"
|
||||
rows="3"
|
||||
placeholder="{{ _('Add notes about this user...') }}"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">{{ user.notes|default('', true) }}</textarea>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 dark:bg-gray-800">
|
||||
<button type="submit"
|
||||
class="inline-flex w-full justify-center rounded-md bg-primary px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-primary_hover sm:ml-3 sm:w-auto">
|
||||
{{ _("Save") }}
|
||||
</button>
|
||||
<button type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
onclick="document.getElementById('modal').classList.add('hidden')">{{ _("Cancel") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if invitation %}
|
||||
<!-- Additional invitation context -->
|
||||
<div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-600">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">{{ _("Invitation Details") }}</h4>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<div>
|
||||
{{ _("Code") }}: <span class="font-mono">{{ invitation.code }}</span>
|
||||
</div>
|
||||
{% if invitation.duration %}<div>{{ _("Duration") }}: {{ invitation.duration }} {{ _("days") }}</div>{% endif %}
|
||||
{% if invitation.expires %}<div>{{ _("Invitation expires") }}: {{ invitation.expires|human_date }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -302,9 +302,9 @@
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-900 dark:bg-opacity-80"></div>
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div id="modal-user" class="relative z-10"></div>
|
||||
<div class="fixed inset-0 modal-backdrop transition-opacity" style="z-index: 0 !important;"></div>
|
||||
<div class="flex min-h-full items-center justify-center p-4" style="position: relative; z-index: 10 !important;">
|
||||
<div id="modal-user"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
|
||||
Reference in New Issue
Block a user