fix: kavita api errors closes #952
This commit is contained in:
parent
a384281609
commit
eca653b10b
6 changed files with 397 additions and 244 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue