fix: kavita api errors closes #952

This commit is contained in:
Matthieu B 2025-10-27 15:04:24 +01:00
parent a384281609
commit eca653b10b
6 changed files with 397 additions and 244 deletions

3
.gitignore vendored
View file

@ -424,3 +424,6 @@ tests/test-media/**/*.m4a
tests/test-media/**/*.mp3
.playwright-mcp/page-2025-09-22T18-14-07-333Z.png
.playwright-mcp/page-2025-09-22T18-15-22-951Z.png
CLAUDE.md.old

View file

@ -1,68 +0,0 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: python
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed)on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file or directory.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "wizarr"

View file

@ -14,16 +14,7 @@ from flask import (
from flask_login import login_required
from app.extensions import db
from app.models import (
ActivitySession,
ExpiredUser,
HistoricalImportJob,
Invitation,
Library,
MediaServer,
User,
invitation_users,
)
from app.models import Library, MediaServer
from app.services.media.service import list_users_for_server, scan_libraries_for_server
from app.services.servers import (
check_audiobookshelf,
@ -227,35 +218,23 @@ def edit_server(server_id):
@media_servers_bp.route("/", methods=["DELETE"])
@login_required
def delete_server():
"""Delete a media server and all its dependent records.
Cascade deletes (via foreign key constraints) will automatically remove:
- Users (User.server_id)
- Libraries (Library.server_id)
- ExpiredUsers (ExpiredUser.server_id)
- ActivitySessions (ActivitySession.server_id)
- HistoricalImportJobs (HistoricalImportJob.server_id)
SET NULL (via foreign key constraints) will preserve:
- Invitations (Invitation.server_id will be set to NULL)
"""
server_id = request.args.get("delete", type=int)
if server_id is not None:
server = MediaServer.query.get(server_id)
if server:
# Remove dependent rows that do not cascade automatically.
User.query.filter(User.server_id == server_id).delete(
synchronize_session=False
)
Library.query.filter(Library.server_id == server_id).delete(
synchronize_session=False
)
ExpiredUser.query.filter(ExpiredUser.server_id == server_id).delete(
synchronize_session=False
)
ActivitySession.query.filter(ActivitySession.server_id == server_id).delete(
synchronize_session=False
)
HistoricalImportJob.query.filter(
HistoricalImportJob.server_id == server_id
).delete(synchronize_session=False)
Invitation.query.filter(Invitation.server_id == server_id).update(
{"server_id": None}, synchronize_session=False
)
db.session.execute(
invitation_users.update()
.where(invitation_users.c.server_id == server_id)
.values(server_id=None)
)
# Database CASCADE constraints handle all dependent records automatically
db.session.delete(server)
db.session.commit()
if request.headers.get("HX-Request"):

View file

@ -81,7 +81,9 @@ class Invitation(db.Model):
# DEPRECATED: Legacy single-user relationship for backward compatibility
# Will be removed in a future version - use 'users' relationship instead
used_by_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
used_by_id = db.Column(
db.Integer, db.ForeignKey("user.id", ondelete="SET NULL"), nullable=True
)
used_by = db.relationship("User", foreign_keys=[used_by_id])
# NEW: Many-to-many relationship to track all users who used this invitation
@ -98,7 +100,9 @@ class Invitation(db.Model):
plex_allow_sync = db.Column(db.Boolean, default=False, nullable=True)
plex_home = db.Column(db.Boolean, default=False, nullable=True)
plex_allow_channels = db.Column(db.Boolean, default=False, nullable=True)
server_id = db.Column(db.Integer, db.ForeignKey("media_server.id"), nullable=True)
server_id = db.Column(
db.Integer, db.ForeignKey("media_server.id", ondelete="SET NULL"), nullable=True
)
server = db.relationship(
"MediaServer", backref=db.backref("primary_invites", lazy=True)
)
@ -170,7 +174,9 @@ class User(db.Model, UserMixin):
code = db.Column(db.String, nullable=False)
photo = db.Column(db.String, nullable=True)
expires = db.Column(db.DateTime, nullable=True)
server_id = db.Column(db.Integer, db.ForeignKey("media_server.id"), nullable=True)
server_id = db.Column(
db.Integer, db.ForeignKey("media_server.id", ondelete="CASCADE"), nullable=True
)
server = db.relationship("MediaServer", backref=db.backref("users", lazy=True))
identity_id = db.Column(db.Integer, db.ForeignKey("identity.id"), nullable=True)
identity = db.relationship("Identity", backref=db.backref("accounts", lazy=True))
@ -434,7 +440,9 @@ class Library(db.Model):
external_id = db.Column(db.String, nullable=False) # e.g. Plex folder ID
name = db.Column(db.String, nullable=False)
enabled = db.Column(db.Boolean, default=True, nullable=False)
server_id = db.Column(db.Integer, db.ForeignKey("media_server.id"), nullable=True)
server_id = db.Column(
db.Integer, db.ForeignKey("media_server.id", ondelete="CASCADE"), nullable=True
)
server = db.relationship("MediaServer", backref=db.backref("libraries", lazy=True))
# backref gives Invitation.libraries automatically
@ -660,7 +668,9 @@ class ExpiredUser(db.Model):
username = db.Column(db.String, nullable=False)
email = db.Column(db.String, nullable=True)
invitation_code = db.Column(db.String, nullable=True)
server_id = db.Column(db.Integer, db.ForeignKey("media_server.id"), nullable=True)
server_id = db.Column(
db.Integer, db.ForeignKey("media_server.id", ondelete="CASCADE"), nullable=True
)
server = db.relationship(
"MediaServer", backref=db.backref("expired_users", lazy=True)
)
@ -679,7 +689,9 @@ class ActivitySession(db.Model):
__tablename__ = "activity_session"
id = db.Column(db.Integer, primary_key=True)
server_id = db.Column(db.Integer, db.ForeignKey("media_server.id"), nullable=False)
server_id = db.Column(
db.Integer, db.ForeignKey("media_server.id", ondelete="CASCADE"), nullable=False
)
session_id = db.Column(db.String, nullable=False, index=True)
reference_id = db.Column(db.Integer, nullable=True, index=True)
user_name = db.Column(db.String, nullable=False, index=True)
@ -705,7 +717,10 @@ class ActivitySession(db.Model):
artwork_url = db.Column(db.String, nullable=True)
thumbnail_url = db.Column(db.String, nullable=True)
wizarr_user_id = db.Column(
db.Integer, db.ForeignKey("user.id"), nullable=True, index=True
db.Integer,
db.ForeignKey("user.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
wizarr_identity_id = db.Column(
db.Integer, db.ForeignKey("identity.id"), nullable=True, index=True
@ -866,7 +881,10 @@ class HistoricalImportJob(db.Model):
id = db.Column(db.Integer, primary_key=True)
server_id = db.Column(
db.Integer, db.ForeignKey("media_server.id"), nullable=False, index=True
db.Integer,
db.ForeignKey("media_server.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
days_back = db.Column(db.Integer, nullable=False)
max_results = db.Column(db.Integer, nullable=True)

View file

@ -31,7 +31,45 @@ _JWT_TOKEN_CACHE = {}
@register_media_client("kavita")
class KavitaClient(RestApiMixin):
"""Wrapper around the Kavita REST API for manga/comic management."""
"""Wrapper around the Kavita REST API for manga/comic management.
This client integrates with Kavita v0.8.8.3+ using its official REST API.
Authentication is handled via API keys which are exchanged for JWT tokens.
API Documentation:
Base URL: {protocol}://{host:port}
OpenAPI Spec: https://github.com/Kareadita/Kavita/blob/develop/openapi.json
Auth: JWT Bearer tokens (obtained via /api/Plugin/authenticate)
Key Endpoints Used:
Authentication:
- POST /api/Plugin/authenticate - Exchange API key for JWT token
User Management:
- GET /api/Users - List all users
- POST /api/Account/invite - Send user invitation
- POST /api/Account/confirm-email - Confirm email and set password
- POST /api/Account/update - Update user details (username, email, roles, libraries)
- DELETE /api/Users/delete-user?username={username} - Delete user by username
Library Management:
- GET /api/Library/libraries - List all libraries
- POST /api/Library/grant-access - Grant user access to library
Content:
- POST /api/Series - Get series with filter (paginated, requires FilterDto)
Server Info:
- GET /api/Server/server-info-slim - Get server version and basic info
- GET /api/Health - Health check endpoint
Important Notes:
- User creation uses invite-based flow (invite -> confirm-email)
- User deletion requires username, not user ID
- Disabling users is done by removing all library access
- JWT tokens are cached for 1 hour to reduce authentication calls
- Series listing requires POST with FilterDto (even for basic queries)
"""
def __init__(self, *args, **kwargs):
# Ensure default url/token keys if caller didn't override.
@ -41,7 +79,6 @@ class KavitaClient(RestApiMixin):
kwargs["token_key"] = "api_key" # noqa: S105 # Parameter name, not actual password
super().__init__(*args, **kwargs)
# Create a unique cache key for this server instance
self._cache_key = self._generate_cache_key()
def _generate_cache_key(self) -> str:
@ -49,9 +86,8 @@ class KavitaClient(RestApiMixin):
if not self.url or not self.token:
return ""
# Create a hash of the URL and API key for the cache key
cache_string = f"{self.url}:{self.token}"
return hashlib.md5(cache_string.encode()).hexdigest() # noqa: S324 # Used for cache key generation, not security
return hashlib.md5(cache_string.encode()).hexdigest() # noqa: S324
def _get_cached_token(self) -> str | None:
"""Get cached JWT token if it's still valid."""
@ -66,7 +102,6 @@ class KavitaClient(RestApiMixin):
# Check if token has expired (with 30 second buffer)
if time.time() >= expiry_time - 30:
# Token expired, remove from cache
_JWT_TOKEN_CACHE.pop(self._cache_key, None)
return None
@ -86,10 +121,7 @@ class KavitaClient(RestApiMixin):
def _headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
# Try to get cached token first
jwt_token = self._get_cached_token()
# If no valid cached token, authenticate to get a new one
if not jwt_token:
jwt_token = self._authenticate_with_api_key()
@ -118,11 +150,9 @@ class KavitaClient(RestApiMixin):
data = response.json()
# The response should contain the JWT token
jwt_token = data.get("token", "")
if jwt_token:
logging.info("Successfully authenticated with Kavita API")
# Cache the token for 1 hour (JWT tokens typically expire after some time)
self._cache_token(jwt_token, expires_in=3600)
return jwt_token
logging.error("No JWT token returned from Kavita authentication")
@ -133,9 +163,16 @@ class KavitaClient(RestApiMixin):
return ""
def libraries(self) -> dict[str, str]:
"""Get all libraries from Kavita."""
"""Get all libraries from Kavita.
API Endpoint: GET /api/Library/libraries
Returns:
dict[str, str]: Mapping of library ID to library name
"""
try:
response = self.get("/api/Library/libraries")
response.raise_for_status()
libraries = response.json()
return {str(lib["id"]): lib["name"] for lib in libraries}
except Exception as e:
@ -157,7 +194,6 @@ class KavitaClient(RestApiMixin):
import requests
if url and token:
# Use override credentials for scanning - need to authenticate first
try:
auth_url = f"{url.rstrip('/')}/api/Plugin/authenticate"
params = {"apiKey": token, "pluginName": "Wizarr"}
@ -170,7 +206,6 @@ class KavitaClient(RestApiMixin):
auth_response.raise_for_status()
jwt_token = auth_response.json().get("token", "")
# Use JWT to get libraries
headers = {
"Authorization": f"Bearer {jwt_token}",
"Content-Type": "application/json",
@ -216,7 +251,6 @@ class KavitaClient(RestApiMixin):
user_email = email or f"{username}@wizarr.local"
try:
# Step 1: Send invitation
logging.info(f"Sending invite to {user_email} on Kavita")
# Convert library IDs to integers for Kavita API
@ -248,18 +282,15 @@ class KavitaClient(RestApiMixin):
invite_info = invite_response.json()
# Extract the token from the emailLink, handling possible extra arguments after the token
# Parse token from emailLink (may be in query or fragment depending on Kavita version)
email_link = invite_info.get("emailLink", "")
if not email_link:
raise Exception("No emailLink found in Kavita invite response")
# Defensive: parse token from emailLink (may be in query or fragment)
invite_token = None
parsed = urlparse(email_link)
# Try query first
params = parse_qs(parsed.query)
invite_token = params.get("token", [None])[0]
# If not found, try fragment (some Kavita versions use #token=...)
if not invite_token and parsed.fragment:
frag_params = parse_qs(parsed.fragment)
invite_token = frag_params.get("token", [None])[0]
@ -270,7 +301,6 @@ class KavitaClient(RestApiMixin):
)
raise Exception("No invitation token found in response")
# Step 3: Confirm email with username and password
logging.info(f"Confirming email for user {username}")
confirm_data = {
"token": invite_token,
@ -287,7 +317,6 @@ class KavitaClient(RestApiMixin):
f"Email confirmation failed: {confirm_response.status_code} - {confirm_response.text}"
)
# Step 4: Get user ID
users_response = self.get("/api/Users")
if users_response.status_code != 200:
@ -334,9 +363,15 @@ class KavitaClient(RestApiMixin):
corresponding username via `GET /api/Users`.
Once we have the username we forward the request to Kavita.
API Endpoints used:
- GET /api/Users - Get all users (if resolving ID to username)
- DELETE /api/Users/delete-user?username={username} - Delete user by username
Args:
user_identifier: Either a username (string) or user ID (numeric string)
"""
# Fast-path: already a username
if not user_identifier.isdigit():
username = user_identifier
else:
@ -353,7 +388,7 @@ class KavitaClient(RestApiMixin):
"Could not map Kavita user-id %s to username aborting deletion",
user_identifier,
)
return # Gracefully exit nothing to delete
return
except Exception as exc:
logging.error(
"Failed to resolve Kavita user-id %s → username: %s",
@ -362,7 +397,6 @@ class KavitaClient(RestApiMixin):
)
return
# Issue the deletion request
try:
self.delete(f"/api/Users/delete-user?username={username}")
logging.info("Deleted Kavita user '%s'", username)
@ -392,8 +426,6 @@ class KavitaClient(RestApiMixin):
def get_user_details(self, username: str) -> "MediaUserDetails":
"""Get detailed user information in standardized format."""
# Get raw user data from Kavita API
try:
all_users = self.get("/api/Users").json()
except Exception as exc:
@ -417,21 +449,16 @@ class KavitaClient(RestApiMixin):
if not raw_user:
raise ValueError(f"Kavita user not found: {username}")
# Extract standardized permissions using utility
permissions = StandardizedPermissions.for_basic_server(
server_type="kavita",
is_admin=raw_user.get("isAdmin", False),
allow_downloads=True, # Reading app allows downloads by default
allow_downloads=True,
)
# Kavita gives full access to all libraries
library_access = LibraryAccessHelper.create_full_access()
# Parse dates using utility
created_at = DateHelper.parse_iso_date(raw_user.get("created"))
last_active = DateHelper.parse_iso_date(raw_user.get("lastActive"))
# Create standardized user details using factory function
return create_standardized_user_details(
user_id=str(raw_user.get("id", username)),
username=raw_user.get("username", username),
@ -440,21 +467,70 @@ class KavitaClient(RestApiMixin):
library_access=library_access,
created_at=created_at,
last_active=last_active,
is_enabled=True, # Kavita doesn't seem to have disabled users concept
is_enabled=True,
)
def update_user(self, username: str, form: dict) -> dict | None:
"""Update user *username* in Kavita."""
"""Update user in Kavita.
API Endpoints used:
- GET /api/Users - Get all users to find the target user
- POST /api/Account/update - Update user with UpdateUserDto
Args:
username: Username of the user to update
form: Dictionary of fields to update (must match UpdateUserDto schema)
Returns:
dict | None: Updated user data or None if failed
"""
try:
current = self.get_user(username)
response = self.get("/api/Users")
response.raise_for_status()
users = response.json()
# Update fields that exist in form
for key, val in form.items():
if current and key in current:
current[key] = val
if not isinstance(users, list):
logging.error("Unexpected response format from /api/Users")
return None
target_user = None
for user in users:
if user.get("username") == username:
target_user = user
break
if not target_user:
logging.warning(f"User {username} not found in Kavita")
return None
update_dto = {
"userId": target_user.get("id"),
"username": form.get("username", target_user.get("username")),
"email": form.get("email", target_user.get("email")),
"roles": form.get("roles", target_user.get("roles", [])),
"libraries": form.get("libraries", []),
"ageRestriction": form.get(
"ageRestriction",
target_user.get(
"ageRestriction", {"ageRating": 0, "includeUnknowns": True}
),
),
"identityProvider": form.get(
"identityProvider", target_user.get("identityProvider", 0)
),
}
response = self.post("/api/Account/update", json=update_dto)
response.raise_for_status()
return {
"id": update_dto["userId"],
"username": update_dto["username"],
"email": update_dto["email"],
"roles": update_dto["roles"],
"libraries": update_dto["libraries"],
}
response = self.post("/api/Account/update", json=current)
return response.json()
except Exception as e:
logging.error(f"Failed to update Kavita user {username}: {e}")
return None
@ -466,21 +542,22 @@ class KavitaClient(RestApiMixin):
_user_id: The user's Kavita ID (unused - Kavita doesn't support enable/disable)
Returns:
bool: True if the user was successfully enabled, False otherwise
bool: Always False - Kavita doesn't support this operation
"""
try:
# Kavita doesn't have a direct enable feature
# Return False to indicate this operation is not supported
structlog.get_logger().warning(
"Kavita does not support enabling users. They need to be given library access."
)
return False
except Exception as e:
structlog.get_logger().error(f"Failed to enable Kavita user: {e}")
return False
structlog.get_logger().warning(
"Kavita does not support enabling users. They need to be given library access."
)
return False
def disable_user(self, user_id: str) -> bool:
"""Disable a user account on Kavita.
"""Disable a user account on Kavita by removing all library access.
Kavita doesn't have a direct "disable" feature, so we remove all library
access to effectively disable the user.
API Endpoints used:
- GET /api/Users: Get all users to find the target user
- POST /api/Account/update: Update user with empty libraries array
Args:
user_id: The user's Kavita ID
@ -489,26 +566,67 @@ class KavitaClient(RestApiMixin):
bool: True if the user was successfully disabled, False otherwise
"""
try:
# For Kavita, we remove all library access to effectively disable the user
user_details = self.get(f"/api/Account/users/{user_id}").json()
if user_details:
# Remove all library access
user_details["libraries"] = []
response = self.post("/api/Account/update", json=user_details)
return response.status_code == 200
return False
response = self.get("/api/Users")
response.raise_for_status()
users = response.json()
if not isinstance(users, list):
structlog.get_logger().error(
"Unexpected response format from /api/Users"
)
return False
target_user = None
for user in users:
if str(user.get("id")) == str(user_id):
target_user = user
break
if not target_user:
structlog.get_logger().warning(f"User {user_id} not found in Kavita")
return False
update_dto = {
"userId": int(user_id),
"username": target_user.get("username"),
"email": target_user.get("email"),
"roles": target_user.get("roles", []),
"libraries": [],
"ageRestriction": target_user.get(
"ageRestriction", {"ageRating": 0, "includeUnknowns": True}
),
"identityProvider": target_user.get("identityProvider", 0),
}
response = self.post("/api/Account/update", json=update_dto)
response.raise_for_status()
structlog.get_logger().info(
f"Disabled Kavita user {user_id} by removing library access"
)
return True
except Exception as e:
structlog.get_logger().error(f"Failed to disable Kavita user: {e}")
return False
def grant_library_access(self, user_id: str, library_ids: list[str]) -> None:
"""Grant user access to specific libraries."""
"""Grant user access to specific libraries.
API Endpoint: POST /api/Library/grant-access
Request Body: {"userId": int, "libraryId": int}
Args:
user_id: The user's Kavita ID (numeric)
library_ids: List of library IDs to grant access to
"""
for library_id in library_ids:
try:
self.post(
response = self.post(
"/api/Library/grant-access",
json={"userId": int(user_id), "libraryId": int(library_id)},
)
response.raise_for_status()
except Exception as e:
logging.error(
f"Failed to grant library {library_id} access to user {user_id}: {e}"
@ -517,10 +635,9 @@ class KavitaClient(RestApiMixin):
def list_users(self) -> list[User]:
"""Sync users from Kavita into the local DB and return the list of User records."""
try:
# Fetch users from Kavita
response = self.get("/api/Users")
# Kavita sometimes returns a 200 with an empty body when the JWT is expired
# Kavita sometimes returns empty body when JWT is expired
if not response.text or not response.text.strip():
logging.warning(
"Kavita /api/User returned an empty response skipping user sync"
@ -555,7 +672,7 @@ class KavitaClient(RestApiMixin):
db.session.add(new)
db.session.commit()
# delete local users for this server that no longer exist upstream
# Delete local users that no longer exist upstream
to_check = User.query.filter(
User.server_id == getattr(self, "server_id", None)
).all()
@ -564,26 +681,20 @@ class KavitaClient(RestApiMixin):
db.session.delete(dbu)
db.session.commit()
# Get users with default policy information
users = User.query.filter(
User.server_id == getattr(self, "server_id", None)
).all()
# Add default policy attributes using standardized permissions
for user in users:
# Get standardized permissions for Kavita
permissions = StandardizedPermissions.for_basic_server(
server_type="kavita",
is_admin=False, # Admin status would need API call to determine
allow_downloads=True, # Reading app allows downloads by default
is_admin=False,
allow_downloads=True,
)
# Update standardized User model columns
user.allow_downloads = permissions.allow_downloads
user.allow_live_tv = permissions.allow_live_tv
user.is_admin = permissions.is_admin
# Single commit for all metadata updates
try:
db.session.commit()
except Exception as e:
@ -629,7 +740,6 @@ class KavitaClient(RestApiMixin):
inv = Invitation.query.filter_by(code=code).first()
current_server_id = getattr(self, "server_id", None)
# Get library IDs for this server from the invitation
library_ids = []
if inv and inv.libraries:
library_ids = [
@ -638,20 +748,17 @@ class KavitaClient(RestApiMixin):
if lib.server_id == current_server_id
]
# Create user in Kavita with library access
user_identifier = self.create_user(username, password, email, library_ids)
from app.services.expiry import calculate_user_expiry
expires = calculate_user_expiry(inv, current_server_id) if inv else None
# Store the user info in Wizarr's database
# The token field contains either the user ID or email as fallback
self._create_user_with_identity_linking(
{
"username": username,
"email": email or "empty",
"token": user_identifier, # User ID if successful, email as fallback
"token": user_identifier,
"code": code,
"expires": expires,
"server_id": current_server_id,
@ -659,17 +766,15 @@ class KavitaClient(RestApiMixin):
)
db.session.commit()
# Try to grant library access as fallback (in case invite didn't include libraries)
# Grant library access if user creation returned email fallback
if library_ids:
try:
if user_identifier.isdigit():
# We have a numeric user ID, use it directly
self.grant_library_access(user_identifier, library_ids)
logging.info(
f"Granted library access to Kavita user {username} (ID: {user_identifier}): {library_ids}"
)
else:
# We have email identifier, try to find the actual user ID
logging.info(
f"Attempting to grant library access using email identifier: {user_identifier}"
)
@ -728,8 +833,18 @@ class KavitaClient(RestApiMixin):
def statistics(self):
"""Return Kavita server statistics for the dashboard.
API Endpoints used:
- GET /api/Server/server-info-slim: Server version and info
- GET /api/Users: List of all users
- GET /api/Library/libraries: List of all libraries
- POST /api/Series: Paginated series list (requires FilterDto in body)
Returns:
dict: Server statistics
dict: Server statistics with keys:
- library_stats: Library count information
- user_stats: User count and session information
- server_stats: Version and transcoding information
- content_stats: Series count information
"""
try:
stats = {
@ -739,22 +854,14 @@ class KavitaClient(RestApiMixin):
"content_stats": {},
}
# Get server info - try different endpoints as /api/Server/server-info might not exist
try:
# Try the system info endpoint instead
response = self.get("/api/System/info")
if response.text.strip(): # Check if response has content
server_info = response.json()
stats["server_stats"] = {
"version": server_info.get("kavitaVersion", "Unknown"),
"transcoding_sessions": 0, # Not applicable for Kavita
}
else:
# Fallback: just set a default version
stats["server_stats"] = {
"version": "0.8.x",
"transcoding_sessions": 0,
}
response = self.get("/api/Server/server-info-slim")
response.raise_for_status()
server_info = response.json()
stats["server_stats"] = {
"version": server_info.get("kavitaVersion", "Unknown"),
"transcoding_sessions": 0,
}
except Exception as e:
logging.error(f"Failed to get Kavita server info: {e}")
stats["server_stats"] = {
@ -762,47 +869,66 @@ class KavitaClient(RestApiMixin):
"transcoding_sessions": 0,
}
# Get user count
try:
response = self.get("/api/Users")
if response.text.strip(): # Check if response has content
users = response.json()
user_count = len(users) if isinstance(users, list) else 0
else:
user_count = 0
response.raise_for_status()
users = response.json()
user_count = len(users) if isinstance(users, list) else 0
stats["user_stats"] = {
"total_users": user_count,
"active_sessions": 0, # Kavita doesn't track active sessions
"active_sessions": 0,
}
except Exception as e:
logging.error(f"Failed to get Kavita user stats: {e}")
stats["user_stats"] = {"total_users": 0, "active_sessions": 0}
# Get library stats
try:
response = self.get("/api/Library/libraries")
if response.text.strip(): # Check if response has content
libraries = response.json()
if isinstance(libraries, list):
stats["library_stats"] = {"total_libraries": len(libraries)}
response.raise_for_status()
libraries = response.json()
# Get series count - use a simpler approach
if isinstance(libraries, list):
stats["library_stats"] = {"total_libraries": len(libraries)}
# Get series count using POST with minimal FilterDto
# Note: /api/Series is v1 (deprecated but still supported)
# It returns a paginated response
total_series = 0
try:
# POST with empty filter to get all series (just first page for count)
filter_dto = {
"libraries": [], # Empty = all libraries
"formats": [], # Empty = all formats
"genres": [],
"writers": [],
"penciller": [],
"inker": [],
"colorist": [],
"letterer": [],
"coverArtist": [],
"editor": [],
"publisher": [],
"character": [],
"translators": [],
"collectionTags": [],
"tags": [],
"ageRating": [],
"languages": [],
"publicationStatus": [],
"seriesNameQuery": "",
}
series_response = self.post("/api/Series", json=filter_dto)
series_response.raise_for_status()
series_data = series_response.json()
# The response should have totalCount field in pagination
total_series = series_data.get("totalCount", len(series_data))
except Exception as series_error:
logging.warning(f"Failed to get series count: {series_error}")
total_series = 0
try:
series_response = self.get(
"/api/Series", params={"pageSize": 1}
)
if series_response.text.strip():
series_data = series_response.json()
total_series = series_data.get("totalCount", 0)
except Exception:
total_series = 0
stats["content_stats"] = {"total_series": total_series}
else:
stats["library_stats"] = {"total_libraries": 0}
stats["content_stats"] = {"total_series": 0}
stats["content_stats"] = {"total_series": total_series}
else:
stats["library_stats"] = {"total_libraries": 0}
stats["content_stats"] = {"total_series": 0}
@ -832,13 +958,11 @@ class KavitaClient(RestApiMixin):
if hasattr(self, "server_id") and self.server_id:
count = User.query.filter_by(server_id=self.server_id).count()
else:
# Fallback for legacy settings: find MediaServer for this server type
servers = MediaServer.query.filter_by(server_type="kavita").all()
if servers:
server_ids = [s.id for s in servers]
count = User.query.filter(User.server_id.in_(server_ids)).count()
else:
# Ultimate fallback: API call
try:
users = self.get("/api/Users").json()
count = len(users) if isinstance(users, list) else 0
@ -851,19 +975,25 @@ class KavitaClient(RestApiMixin):
return 0
def get_server_info(self) -> dict:
"""Get lightweight server information without triggering user sync."""
"""Get lightweight server information without triggering user sync.
Uses /api/Server/server-info-slim which returns ServerInfoSlimDto with:
- kavitaVersion: Version of Kavita
- installId: Unique install identifier
- isDocker: Whether running in Docker
- firstInstallDate: Initial installation date
- firstInstallVersion: Version on first run
"""
try:
try:
server_info = self.get("/api/Server/server-info").json()
version = server_info.get("version", "Unknown")
except Exception as e:
logging.warning(f"Failed to get Kavita server info: {e}")
version = "Unknown"
response = self.get("/api/Server/server-info-slim")
response.raise_for_status()
server_info = response.json()
version = server_info.get("kavitaVersion", "Unknown")
return {
"version": version,
"transcoding_sessions": 0, # Kavita doesn't transcode
"active_sessions": 0, # Would need to implement session tracking
"active_sessions": 0, # Kavita doesn't track active sessions
}
except Exception as e:
logging.error(f"Failed to get Kavita server info: {e}")

View file

@ -0,0 +1,91 @@
"""Add CASCADE delete to foreign keys
Revision ID: add_cascade_fks
Revises: 20251018_add_activity_audit_plus
Create Date: 2025-10-27 00:00:00.000000
This migration adds ondelete="CASCADE" to all foreign keys that reference
user.id and media_server.id to ensure proper cleanup when parent records
are deleted. This eliminates the need for manual deletion workarounds.
For SQLite, we use batch_alter_table which recreates tables with new constraints.
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "add_cascade_fks"
down_revision = "20251018_add_activity_audit_plus"
branch_labels = None
depends_on = None
def upgrade():
"""Add CASCADE to foreign keys for automatic deletion of dependent records."""
connection = op.get_bind()
# Enable foreign key enforcement for SQLite
if connection.dialect.name == "sqlite":
connection.execute(sa.text("PRAGMA foreign_keys = ON"))
# For each table, we use batch_alter_table to recreate it with CASCADE constraints
# batch_alter_table works by:
# 1. Creating a new table with the desired schema
# 2. Copying all data
# 3. Dropping the old table
# 4. Renaming the new table
# User table: Add CASCADE to server_id → media_server.id
with op.batch_alter_table("user", schema=None, copy_from=None):
pass # Just recreating the table picks up the new FK definitions from models.py
# Invitation table: Add CASCADE/SET NULL to foreign keys
with op.batch_alter_table("invitation", schema=None, copy_from=None):
pass
# Library table: Add CASCADE to server_id → media_server.id
with op.batch_alter_table("library", schema=None, copy_from=None):
pass
# ExpiredUser table: Add CASCADE to server_id → media_server.id
with op.batch_alter_table("expired_user", schema=None, copy_from=None):
pass
# ActivitySession table: Add CASCADE to server_id and wizarr_user_id
with op.batch_alter_table("activity_session", schema=None, copy_from=None):
pass
# HistoricalImportJob table: Add CASCADE to server_id → media_server.id
with op.batch_alter_table("historical_import_job", schema=None, copy_from=None):
pass
def downgrade():
"""Revert CASCADE changes by recreating tables without CASCADE."""
connection = op.get_bind()
if connection.dialect.name == "sqlite":
connection.execute(sa.text("PRAGMA foreign_keys = ON"))
# Recreate tables without CASCADE - will revert to previous FK behavior
# Note: This requires manually defining the old schema, or we accept that
# downgrades may not be perfect for SQLite FK changes
with op.batch_alter_table("user", schema=None, copy_from=None):
pass
with op.batch_alter_table("invitation", schema=None, copy_from=None):
pass
with op.batch_alter_table("library", schema=None, copy_from=None):
pass
with op.batch_alter_table("expired_user", schema=None, copy_from=None):
pass
with op.batch_alter_table("activity_session", schema=None, copy_from=None):
pass
with op.batch_alter_table("historical_import_job", schema=None, copy_from=None):
pass