From eca653b10b5a4e4e49c63278afc51fc502f3851b Mon Sep 17 00:00:00 2001 From: Matthieu B Date: Mon, 27 Oct 2025 15:04:24 +0100 Subject: [PATCH] fix: kavita api errors closes #952 --- .gitignore | 3 + .serena/project.yml | 68 --- app/blueprints/media_servers/routes.py | 49 +-- app/models.py | 34 +- app/services/media/kavita.py | 396 ++++++++++++------ ...1027_add_cascade_delete_to_foreign_keys.py | 91 ++++ 6 files changed, 397 insertions(+), 244 deletions(-) delete mode 100644 .serena/project.yml create mode 100644 migrations/versions/20251027_add_cascade_delete_to_foreign_keys.py diff --git a/.gitignore b/.gitignore index cfd27b53..9afc878c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.serena/project.yml b/.serena/project.yml deleted file mode 100644 index aa40a85a..00000000 --- a/.serena/project.yml +++ /dev/null @@ -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" diff --git a/app/blueprints/media_servers/routes.py b/app/blueprints/media_servers/routes.py index b3280b47..7e137630 100644 --- a/app/blueprints/media_servers/routes.py +++ b/app/blueprints/media_servers/routes.py @@ -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"): diff --git a/app/models.py b/app/models.py index 865089c6..82bb8be3 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/services/media/kavita.py b/app/services/media/kavita.py index 982d6c37..08507a6e 100644 --- a/app/services/media/kavita.py +++ b/app/services/media/kavita.py @@ -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}") diff --git a/migrations/versions/20251027_add_cascade_delete_to_foreign_keys.py b/migrations/versions/20251027_add_cascade_delete_to_foreign_keys.py new file mode 100644 index 00000000..092a5094 --- /dev/null +++ b/migrations/versions/20251027_add_cascade_delete_to_foreign_keys.py @@ -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