diff --git a/modules/validations.py b/modules/validations.py index 6d0ad681..9c8bc293 100644 --- a/modules/validations.py +++ b/modules/validations.py @@ -1,5 +1,6 @@ import re import urllib.parse +import re from json import JSONDecodeError import requests @@ -9,6 +10,9 @@ from modules import iso, helpers, url_validation +GITHUB_API = "https://api.github.com" +GITHUB_API_VERSION = "2022-11-28" + def validate_iso3166_1(code): try: @@ -77,6 +81,8 @@ def validate_plex_server(data): helpers.ts_log(f"Movie libraries: {movie_libraries}", level="INFO") helpers.ts_log(f"Show libraries: {show_libraries}", level="INFO") + plex_version = getattr(plex, "version", None) + except Exception as e: helpers.ts_log(f"Error validating Plex server: {str(e)}", level="ERROR") flash(f"Invalid Plex URL or Token: {str(e)}", "error") @@ -92,6 +98,7 @@ def validate_plex_server(data): "movie_libraries": movie_libraries, "show_libraries": show_libraries, "has_plex_pass": has_plex_pass, + "plex_version": plex_version, } ) @@ -128,7 +135,13 @@ def validate_tautulli_server(data): return jsonify({"valid": False, "error": f"Invalid Tautulli URL or Apikey: {str(e)}"}) # return success response - return jsonify({"valid": is_valid}) + tautulli_version = None + if isinstance(data, dict): + info = data.get("response", {}).get("data", {}) + if isinstance(info, dict): + tautulli_version = info.get("tautulli_version") or info.get("version") + + return jsonify({"valid": is_valid, "tautulli_version": tautulli_version}) def validate_trakt_server(data): @@ -178,6 +191,7 @@ def validate_trakt_server(data): "trakt_authorization_refresh_token": response.json()["refresh_token"], "trakt_authorization_scope": response.json()["scope"], "trakt_authorization_created_at": response.json()["created_at"], + "trakt_version": "v2", } ) @@ -217,6 +231,10 @@ def validate_gotify_server(data): if response.status_code >= 400: return jsonify({"valid": False, "error": f"({response.status_code} [{response.reason}]) {response_json['errorDescription']}"}) + gotify_version = None + if isinstance(response_json, dict): + gotify_version = response_json.get("version") or response_json.get("tag") or response_json.get("Version") + json = {"message": "Kometa Quickstart Test Gotify Message", "title": "Kometa Quickstart Gotify Test"} response = requests.post(f"{gotify_url}/message", headers={"X-Gotify-Key": gotify_token}, json=json) @@ -224,7 +242,7 @@ def validate_gotify_server(data): if response.status_code != 200: return jsonify({"valid": False, "error": f"({response.status_code} [{response.reason}]) {response_json['errorDescription']}"}) - return jsonify({"valid": True}) + return jsonify({"valid": True, "gotify_version": gotify_version}) def validate_ntfy_server(data): @@ -251,6 +269,10 @@ def validate_ntfy_server(data): if response.status_code != 200: return jsonify({"valid": False, "error": f"Failed to send test message ({response.status_code} [{response.reason}])."}) + helpers.ts_log( + f"ntfy publish server-header={response.headers.get('Server')}", + level="DEBUG", + ) # Step 2: Auto-subscribe the sender to the topic sub_headers = headers.copy() @@ -258,8 +280,91 @@ def validate_ntfy_server(data): sub_response = requests.put(f"{ntfy_url}/{ntfy_topic}", headers=sub_headers) + def fetch_ntfy_version(base_url, token=None): + try: + + def extract_version_from_text(text): + if not text: + return None + match = re.search(r'"version"\s*:\s*"([^"]+)"', text) + if match: + return match.group(1).strip() + return None + + def extract_version(info_response): + if info_response.status_code != 200: + return None + try: + info_json = info_response.json() + except Exception: + info_json = None + if isinstance(info_json, dict): + version = info_json.get("version") + if isinstance(version, str) and version.strip(): + return version.strip() + return extract_version_from_text(getattr(info_response, "text", "")) + return None + + def build_headers(with_token): + merged = {"Accept": "application/json"} + if with_token and token: + merged["Authorization"] = f"Bearer {token}" + return merged + + info_url = f"{base_url}/v1/info" + info_response = requests.get( + f"{base_url}/v1/info", + headers=build_headers(with_token=True), + timeout=10, + ) + helpers.ts_log( + f"ntfy /v1/info url={info_url} status={info_response.status_code} content-type={info_response.headers.get('Content-Type')} " + f"body={str(getattr(info_response, 'text', '')).strip()[:500]}", + level="DEBUG", + ) + version = extract_version(info_response) + if version: + return version + if token: + fallback_response = requests.get( + info_url, + headers=build_headers(with_token=False), + timeout=10, + ) + helpers.ts_log( + f"ntfy /v1/info fallback url={info_url} status={fallback_response.status_code} content-type={fallback_response.headers.get('Content-Type')} " + f"body={str(getattr(fallback_response, 'text', '')).strip()[:500]}", + level="DEBUG", + ) + return extract_version(fallback_response) + except Exception: + pass + return None + + def extract_ntfy_version(*responses): + for resp in responses: + if not resp: + continue + server_header = resp.headers.get("Server") or resp.headers.get("server") + if not server_header: + server_header = "" + for token in server_header.replace(";", " ").split(): + if token.lower().startswith("ntfy/"): + return token.split("/", 1)[1] + for header_value in resp.headers.values(): + if not header_value or not isinstance(header_value, str): + continue + for token in header_value.replace(";", " ").split(): + if token.lower().startswith("ntfy/"): + return token.split("/", 1)[1] + return None + + ntfy_version = fetch_ntfy_version(ntfy_url, token=ntfy_token) or extract_ntfy_version(response, sub_response) + if not ntfy_version: + ntfy_version = "N/A" + if sub_response.status_code == 200: - return jsonify({"valid": True}) + return jsonify({"valid": True, "ntfy_version": ntfy_version}) else: return jsonify({"valid": False, "error": f"Failed to auto-subscribe ({sub_response.status_code} [{sub_response.reason}])."}) @@ -300,6 +405,7 @@ def validate_mal_server(data): "mal_authorization_token_type": new_authorization["token_type"], "mal_authorization_expires_in": new_authorization["expires_in"], "mal_authorization_refresh_token": new_authorization["refresh_token"], + "mal_version": "v2", } ) @@ -347,6 +453,8 @@ def validate_radarr_server(data): helpers.ts_log("Radarr connection failed. Invalid response data.") return jsonify({"valid": False, "error": "Invalid Radarr URL or Apikey"}) + radarr_version = status_data.get("version") + # Fetch root folders response = requests.get(root_folder_api_url) response.raise_for_status() @@ -364,6 +472,7 @@ def validate_radarr_server(data): "valid": True, "root_folders": root_folders, "quality_profiles": quality_profiles, + "radarr_version": radarr_version, } ) @@ -396,6 +505,8 @@ def validate_sonarr_server(data): helpers.ts_log("Sonarr connection failed. Invalid response data.") return jsonify({"valid": False, "error": "Invalid Sonarr URL or Apikey"}) + sonarr_version = status_data.get("version") + # Fetch root folders response = requests.get(root_folder_api_url) response.raise_for_status() @@ -419,6 +530,7 @@ def validate_sonarr_server(data): "root_folders": root_folders, "quality_profiles": quality_profiles, "language_profiles": language_profiles, + "sonarr_version": sonarr_version, } ) @@ -436,7 +548,10 @@ def validate_omdb_server(data): response = requests.get(api_url) data = response.json() if data.get("Response") == "True" or data.get("Error") == "Movie not found!": - return jsonify({"valid": True, "message": "OMDb API key is valid"}) + omdb_version = data.get("Version") or data.get("version") or response.headers.get("X-API-Version") or response.headers.get("X-Api-Version") + if not omdb_version: + omdb_version = "N/A" + return jsonify({"valid": True, "message": "OMDb API key is valid", "omdb_version": omdb_version}) else: return jsonify({"valid": False, "message": data.get("Error", "Invalid API key")}) except Exception as e: @@ -449,16 +564,42 @@ def validate_github_server(data): github_token = data.get("github_token") try: + version_headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": GITHUB_API_VERSION, + } + github_version = None + try: + versions_response = requests.get(f"{GITHUB_API}/versions", headers=version_headers, timeout=10) + if versions_response.status_code == 200: + github_version = versions_response.headers.get("X-GitHub-Api-Version") + except requests.RequestException: + github_version = None + response = requests.get( - "https://api.github.com/user", + f"{GITHUB_API}/user", headers={ - "Authorization": f"token {github_token}", - "Accept": "application/vnd.github.v3+json", + "Authorization": f"Bearer {github_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": GITHUB_API_VERSION, }, + timeout=10, ) if response.status_code == 200: user_data = response.json() - return jsonify({"valid": True, "message": f"GitHub token is valid. User: {user_data.get('login')}"}) + if not github_version: + github_version = response.headers.get("X-GitHub-Api-Version") or response.headers.get("X-API-Version-Selected") + if not github_version: + github_version = GITHUB_API_VERSION + if not github_version: + github_version = "N/A" + return jsonify( + { + "valid": True, + "message": f"GitHub token is valid. User: {user_data.get('login')}", + "github_version": github_version, + } + ) else: return jsonify({"valid": False, "message": "Invalid GitHub token"}), 400 except Exception as e: @@ -471,7 +612,7 @@ def validate_tmdb_server(data): # Validate the API key movie_response = requests.get(f"https://api.themoviedb.org/3/movie/550?api_key={api_key}") if movie_response.status_code == 200: - return jsonify({"valid": True, "message": "API key is valid!"}) + return jsonify({"valid": True, "message": "API key is valid!", "tmdb_version": "v3"}) else: return jsonify({"valid": False, "message": "Invalid API key"}) @@ -480,8 +621,16 @@ def validate_mdblist_server(data): api_key = data.get("mdblist_apikey") response = requests.get(f"https://mdblist.com/api/?apikey={api_key}&s=test") - if response.status_code == 200 and response.json().get("response") is True: - return jsonify({"valid": True, "message": "API key is valid!"}) + response_data = {} + try: + response_data = response.json() + except ValueError: + response_data = {} + if response.status_code == 200 and response_data.get("response") is True: + mdblist_version = response_data.get("version") or response_data.get("api_version") + if not mdblist_version: + mdblist_version = "N/A" + return jsonify({"valid": True, "message": "API key is valid!", "mdblist_version": mdblist_version}) else: return jsonify({"valid": False, "message": "Invalid API key"}) @@ -490,7 +639,18 @@ def validate_notifiarr_server(data): api_key = data.get("notifiarr_apikey") response = requests.get(f"https://notifiarr.com/api/v1/user/validate/{api_key}") - if response.status_code == 200 and response.json().get("result") == "success": - return jsonify({"valid": True, "message": "API key is valid!"}) + response_data = {} + if response.status_code == 200: + try: + response_data = response.json() + except ValueError: + response_data = {} + if response.status_code == 200 and response_data.get("result") == "success": + notifiarr_version = None + if isinstance(response_data, dict): + notifiarr_version = response_data.get("version") or response_data.get("build") or response_data.get("serverVersion") + if not notifiarr_version: + notifiarr_version = "N/A" + return jsonify({"valid": True, "message": "API key is valid!", "notifiarr_version": notifiarr_version}) else: return jsonify({"valid": False, "message": "Invalid API key"}) diff --git a/quickstart.py b/quickstart.py index 13542073..8082acad 100644 --- a/quickstart.py +++ b/quickstart.py @@ -115,6 +115,72 @@ def utc_now_iso(): return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") +def apply_validation_metadata(stored_data, status, reason=None, details=None, updated_at=None): + if not isinstance(stored_data, dict): + stored_data = {} + stored_data["validation_status"] = status + if reason is not None: + stored_data["validation_reason"] = reason + if details is not None: + stored_data["validation_details"] = details + stored_data["validation_updated_at"] = updated_at or utc_now_iso() + return stored_data + + +def persist_validation_metadata(section, status, reason=None, details=None, validated_override=None): + config_name = session.get("config_name") or persistence.ensure_session_config_name() + stored_validated, user_entered, stored_data = database.retrieve_section_data(config_name, section) + stored_data = apply_validation_metadata(stored_data, status, reason=reason, details=details) + validated_value = stored_validated if validated_override is None else validated_override + database.save_section_data( + name=config_name, + section=section, + validated=validated_value, + user_entered=user_entered, + data=stored_data, + ) + + +def persist_validation_from_response(section, response_data, version_key=None): + if not isinstance(response_data, dict): + return + is_valid = helpers.booler(response_data.get("validated", response_data.get("valid", False))) + if is_valid: + version_value = None + if version_key: + raw_version = response_data.get(version_key) + if raw_version is not None: + raw_text = str(raw_version).strip() + if raw_text: + version_value = raw_text + if version_value is None: + version_value = "N/A" + detail_value = f"Version {version_value}" if version_value else None + persist_validation_metadata(section, "validated", details=detail_value, validated_override=True) + return + message = response_data.get("message") or response_data.get("error") + fail_reason = None + if isinstance(message, str) and "invalid" in message.lower(): + fail_reason = "token_invalid" + else: + fail_reason = "validation_error" + persist_validation_metadata(section, "failed", reason=fail_reason, details=message, validated_override=False) + + +def _response_json(payload): + if isinstance(payload, tuple) and payload: + payload = payload[0] + if hasattr(payload, "get_json"): + return payload.get_json() + return payload + + +def _persist_and_return(section, response, version_key=None): + response_data = _response_json(response) + persist_validation_from_response(section, response_data, version_key=version_key) + return response + + def build_validation_summary(errors): summary = [] if not errors: @@ -3023,6 +3089,15 @@ def add_offset_vars(config): validation_status = stored_payload.get("validation_status") validation_reason = stored_payload.get("validation_reason") validation_details = stored_payload.get("validation_details") + if section_name == "plex" and not validation_details: + telemetry_section = database.retrieve_section_data(config_name, "plex_telemetry") + telemetry_payload = telemetry_section[2] if telemetry_section else None + if isinstance(telemetry_payload, dict): + plex_telemetry = telemetry_payload.get("plex_telemetry", {}) + if isinstance(plex_telemetry, dict): + plex_version = plex_telemetry.get("version") + if isinstance(plex_version, str) and plex_version.strip(): + validation_details = f"Version {plex_version.strip()}" if not validation_status and has_validation: if helpers.booler(settings.get("validated", False)): validation_status = "validated" @@ -3032,19 +3107,19 @@ def add_offset_vars(config): validation_result = "" if validation_status: label = validation_status.capitalize() + detail_text = "" + if isinstance(validation_details, (list, tuple)): + detail_text = ", ".join(str(item) for item in validation_details if str(item)) + elif validation_details is not None: + detail_text = str(validation_details) if validation_reason: pretty = VALIDATION_REASON_LABELS.get(validation_reason, validation_reason.replace("_", " ")) - detail_text = "" - if isinstance(validation_details, (list, tuple)): - detail_text = ", ".join(str(item) for item in validation_details if str(item)) - elif validation_details is not None: - detail_text = str(validation_details) if detail_text: validation_result = f"{label}: {pretty}: {detail_text}" else: validation_result = f"{label}: {pretty}" else: - validation_result = label + validation_result = f"{label}: {detail_text}" if detail_text else label validation_meta.append( { @@ -3594,7 +3669,8 @@ def validate_gotify(): valid, message = url_validation.validate_url(data.get("gotify_url"), allow_local=True) if not valid: return jsonify({"valid": False, "error": f"Gotify URL: {message}"}), 400 - return validations.validate_gotify_server(data) + response = validations.validate_gotify_server(data) + return _persist_and_return("gotify", response, version_key="gotify_version") @app.route("/validate_ntfy", methods=["POST"]) @@ -3603,7 +3679,8 @@ def validate_ntfy(): valid, message = url_validation.validate_url(data.get("ntfy_url"), allow_local=True) if not valid: return jsonify({"valid": False, "error": f"ntfy URL: {message}"}), 400 - return validations.validate_ntfy_server(data) + response = validations.validate_ntfy_server(data) + return _persist_and_return("ntfy", response, version_key="ntfy_version") @app.route("/validate_plex", methods=["POST"]) @@ -3612,7 +3689,8 @@ def validate_plex(): valid, message = url_validation.validate_url(data.get("plex_url"), allow_local=True) if not valid: return jsonify({"valid": False, "error": f"Plex URL: {message}"}), 400 - return validations.validate_plex_server(data) + response = validations.validate_plex_server(data) + return _persist_and_return("plex", response, version_key="plex_version") @app.route("/path-validation-rules", methods=["GET"]) @@ -3684,13 +3762,15 @@ def refresh_plex_libraries(): @app.route("/validate_tautulli", methods=["POST"]) def validate_tautulli(): data = request.json - return validations.validate_tautulli_server(data) + response = validations.validate_tautulli_server(data) + return _persist_and_return("tautulli", response, version_key="tautulli_version") @app.route("/validate_trakt", methods=["POST"]) def validate_trakt(): data = request.json - return validations.validate_trakt_server(data) + response = validations.validate_trakt_server(data) + return _persist_and_return("trakt", response, version_key="trakt_version") @app.route("/validate_trakt_token", methods=["POST"]) @@ -3702,6 +3782,12 @@ def validate_trakt_token(): refresh_token = data.get("refresh_token") debug_enabled = helpers.booler(app.config.get("QS_DEBUG", False)) or helpers.booler(data.get("debug", False)) + def respond(payload, status=None): + persist_validation_from_response("trakt", payload, version_key="trakt_version") + if status is None: + return jsonify(payload) + return jsonify(payload), status + def is_blank(value): if value is None: return True @@ -3748,7 +3834,7 @@ def is_blank(value): response = {"valid": False, "error": "Missing Trakt access token or client ID."} if debug_payload: response["debug"] = debug_payload - return jsonify(response), 400 + return respond(response, 400) try: response = requests.get( "https://api.trakt.tv/users/settings", @@ -3763,12 +3849,12 @@ def is_blank(value): if debug_enabled: helpers.ts_log(f"Trakt token check status={response.status_code}", level="DEBUG") if response.status_code == 200: - return jsonify({"valid": True}) + return respond({"valid": True, "trakt_version": "v2"}) if response.status_code == 423: - return jsonify({"valid": False, "error": "Account is locked; please contact Trakt Support."}), 400 + return respond({"valid": False, "error": "Account is locked; please contact Trakt Support."}, 400) if response.status_code in (401, 403): if is_blank(refresh_token) or is_blank(client_secret): - return jsonify({"valid": False, "error": "Access token is invalid or expired."}), 400 + return respond({"valid": False, "error": "Access token is invalid or expired."}, 400) refresh_response = requests.post( "https://api.trakt.tv/oauth/token", @@ -3792,12 +3878,12 @@ def is_blank(value): response_body = {"valid": False, "error": "Access token is invalid or expired."} if debug_payload: response_body["debug"] = debug_payload - return jsonify(response_body), 400 + return respond(response_body, 400) refreshed = refresh_response.json() new_access = refreshed.get("access_token") if is_blank(new_access): - return jsonify({"valid": False, "error": "Access token refresh failed."}), 400 + return respond({"valid": False, "error": "Access token refresh failed."}, 400) config_name = session.get("config_name") or persistence.ensure_session_config_name() stored_validated, user_entered, stored_data = database.retrieve_section_data(config_name, "trakt") @@ -3827,23 +3913,24 @@ def is_blank(value): user_entered=user_entered, data=stored_data, ) - return jsonify({"valid": True, "refreshed": True, "authorization": auth}) + return respond({"valid": True, "refreshed": True, "authorization": auth, "trakt_version": "v2"}) response_body = {"valid": False, "error": f"Trakt validation failed ({response.status_code})."} if debug_enabled: response_body["debug"] = {"status": response.status_code} - return jsonify(response_body), 400 + return respond(response_body, 400) except requests.exceptions.RequestException as exc: helpers.ts_log(f"Trakt validation error: {exc}", level="ERROR") response_body = {"valid": False, "error": "Trakt validation error."} if debug_enabled: response_body["debug"] = {"status": "request_exception"} - return jsonify(response_body), 400 + return respond(response_body, 400) @app.route("/validate_mal", methods=["POST"]) def validate_mal(): data = request.json - return validations.validate_mal_server(data) + response = validations.validate_mal_server(data) + return _persist_and_return("mal", response, version_key="mal_version") @app.route("/validate_mal_token", methods=["POST"]) @@ -3852,6 +3939,12 @@ def validate_mal_token(): access_token = data.get("access_token") debug_enabled = helpers.booler(app.config.get("QS_DEBUG", False)) or helpers.booler(data.get("debug", False)) + def respond(payload, status=None): + persist_validation_from_response("mal", payload, version_key="mal_version") + if status is None: + return jsonify(payload) + return jsonify(payload), status + def is_blank(value): if value is None: return True @@ -3881,7 +3974,7 @@ def is_blank(value): response = {"valid": False, "error": "Missing MyAnimeList access token."} if debug_payload: response["debug"] = debug_payload - return jsonify(response), 400 + return respond(response, 400) try: response = requests.get( "https://api.myanimelist.net/v2/users/@me", @@ -3889,13 +3982,13 @@ def is_blank(value): timeout=10, ) if response.status_code == 200: - return jsonify({"valid": True}) + return respond({"valid": True, "mal_version": "v2"}) if response.status_code in (401, 403): - return jsonify({"valid": False, "error": "Access token is invalid or expired."}), 400 - return jsonify({"valid": False, "error": f"MyAnimeList validation failed ({response.status_code})."}), 400 + return respond({"valid": False, "error": "Access token is invalid or expired."}, 400) + return respond({"valid": False, "error": f"MyAnimeList validation failed ({response.status_code})."}, 400) except requests.exceptions.RequestException as exc: helpers.ts_log(f"MyAnimeList validation error: {exc}", level="ERROR") - return jsonify({"valid": False, "error": "MyAnimeList validation error."}), 400 + return respond({"valid": False, "error": "MyAnimeList validation error."}, 400) @app.route("/validate_webhook", methods=["POST"]) @@ -3908,77 +4001,77 @@ def validate_webhook(): def validate_radarr(): data = request.json result = validations.validate_radarr_server(data) - - if result.get_json().get("valid"): - return jsonify(result.get_json()) - else: - return jsonify(result.get_json()), 400 + payload = _response_json(result) or {} + persist_validation_from_response("radarr", payload, version_key="radarr_version") + if helpers.booler(payload.get("valid", payload.get("validated", False))): + return jsonify(payload) + return jsonify(payload), 400 @app.route("/validate_sonarr", methods=["POST"]) def validate_sonarr(): data = request.json result = validations.validate_sonarr_server(data) - - if result.get_json().get("valid"): - return jsonify(result.get_json()) - else: - return jsonify(result.get_json()), 400 + payload = _response_json(result) or {} + persist_validation_from_response("sonarr", payload, version_key="sonarr_version") + if helpers.booler(payload.get("valid", payload.get("validated", False))): + return jsonify(payload) + return jsonify(payload), 400 @app.route("/validate_omdb", methods=["POST"]) def validate_omdb(): data = request.json result = validations.validate_omdb_server(data) - - if result.get_json().get("valid"): - return jsonify(result.get_json()) - else: - return jsonify(result.get_json()), 400 + payload = _response_json(result) or {} + persist_validation_from_response("omdb", payload, version_key="omdb_version") + if helpers.booler(payload.get("valid", payload.get("validated", False))): + return jsonify(payload) + return jsonify(payload), 400 @app.route("/validate_github", methods=["POST"]) def validate_github(): data = request.json result = validations.validate_github_server(data) - - if result.get_json().get("valid"): - return jsonify(result.get_json()) - else: - return jsonify(result.get_json()), 400 + payload = _response_json(result) or {} + persist_validation_from_response("github", payload, version_key="github_version") + if helpers.booler(payload.get("valid", payload.get("validated", False))): + return jsonify(payload) + return jsonify(payload), 400 @app.route("/validate_tmdb", methods=["POST"]) def validate_tmdb(): data = request.json result = validations.validate_tmdb_server(data) - - if result.get_json().get("valid"): - return jsonify(result.get_json()) - else: - return jsonify(result.get_json()), 400 + payload = _response_json(result) or {} + persist_validation_from_response("tmdb", payload, version_key="tmdb_version") + if helpers.booler(payload.get("valid", payload.get("validated", False))): + return jsonify(payload) + return jsonify(payload), 400 @app.route("/validate_mdblist", methods=["POST"]) def validate_mdblist(): data = request.json result = validations.validate_mdblist_server(data) - - if result.get_json().get("valid"): - return jsonify(result.get_json()) - else: - return jsonify(result.get_json()), 400 + payload = _response_json(result) or {} + persist_validation_from_response("mdblist", payload, version_key="mdblist_version") + if helpers.booler(payload.get("valid", payload.get("validated", False))): + return jsonify(payload) + return jsonify(payload), 400 @app.route("/validate_notifiarr", methods=["POST"]) def validate_notifiarr(): data = request.json result = validations.validate_notifiarr_server(data) - - if result.get_json().get("valid"): - return jsonify(result.get_json()) - else: - return jsonify(result.get_json()), 400 + payload = _response_json(result) or {} + persist_validation_from_response("notifiarr", payload, version_key="notifiarr_version") + if helpers.booler(payload.get("valid", payload.get("validated", False))): + return jsonify(payload) + return jsonify(payload), 400 @app.route("/validate_all_services", methods=["POST"]) @@ -4007,29 +4100,6 @@ def has_required_credentials(payload, required_keys): return False return True - def apply_validation_metadata(stored_data, status, reason=None, details=None, updated_at=None): - if not isinstance(stored_data, dict): - stored_data = {} - stored_data["validation_status"] = status - if reason is not None: - stored_data["validation_reason"] = reason - if details is not None: - stored_data["validation_details"] = details - stored_data["validation_updated_at"] = updated_at or utc_now_iso() - return stored_data - - def persist_validation_metadata(section, status, reason=None, details=None, validated_override=None): - stored_validated, user_entered, stored_data = database.retrieve_section_data(config_name, section) - stored_data = apply_validation_metadata(stored_data, status, reason=reason, details=details) - validated_value = stored_validated if validated_override is None else validated_override - database.save_section_data( - name=config_name, - section=section, - validated=validated_value, - user_entered=user_entered, - data=stored_data, - ) - targets = [ ( "010-plex", @@ -4082,6 +4152,19 @@ def persist_validation_metadata(section, status, reason=None, details=None, vali results = {} summary = {"validated": 0, "failed": 0, "skipped": 0} + version_fields = { + "010-plex": "plex_version", + "020-tmdb": "tmdb_version", + "030-tautulli": "tautulli_version", + "040-github": "github_version", + "050-omdb": "omdb_version", + "060-mdblist": "mdblist_version", + "070-notifiarr": "notifiarr_version", + "080-gotify": "gotify_version", + "085-ntfy": "ntfy_version", + "110-radarr": "radarr_version", + "120-sonarr": "sonarr_version", + } for template_key, section, validator, payload_builder, required_keys in targets: settings = persistence.retrieve_settings(template_key) @@ -4106,6 +4189,17 @@ def persist_validation_metadata(section, status, reason=None, details=None, vali except Exception as e: response_data = {"valid": False, "error": str(e)} + version_value = None + version_key = version_fields.get(template_key) + if version_key and isinstance(response_data, dict): + raw_version = response_data.get(version_key) + if raw_version is not None: + raw_text = str(raw_version).strip() + if raw_text: + version_value = raw_text + if version_value is None: + version_value = "N/A" + is_valid = helpers.booler(response_data.get("validated", response_data.get("valid", False))) stored_validated, user_entered, stored_data = database.retrieve_section_data(config_name, section) if not isinstance(stored_data, dict): @@ -4116,7 +4210,8 @@ def persist_validation_metadata(section, status, reason=None, details=None, vali new_validated_at = utc_now_iso() stored_data["validated"] = True stored_data["validated_at"] = new_validated_at - stored_data = apply_validation_metadata(stored_data, "validated") + detail_value = f"Version {version_value}" if version_value else None + stored_data = apply_validation_metadata(stored_data, "validated", details=detail_value) database.save_section_data( name=config_name, section=section, @@ -4125,6 +4220,8 @@ def persist_validation_metadata(section, status, reason=None, details=None, vali data=stored_data, ) results[template_key] = {"status": "validated", "validated_at": new_validated_at} + if detail_value: + results[template_key]["details"] = detail_value summary["validated"] += 1 else: stored_data["validated"] = False @@ -4159,7 +4256,7 @@ def update_section_validation(template_key, section, is_valid, reason=None, deta new_validated_at = utc_now_iso() stored_data["validated"] = True stored_data["validated_at"] = new_validated_at - stored_data = apply_validation_metadata(stored_data, "validated") + stored_data = apply_validation_metadata(stored_data, "validated", details=details) database.save_section_data( name=config_name, section=section, @@ -4167,7 +4264,10 @@ def update_section_validation(template_key, section, is_valid, reason=None, deta user_entered=user_entered, data=stored_data, ) - results[template_key] = {"status": "validated", "validated_at": new_validated_at} + result = {"status": "validated", "validated_at": new_validated_at} + if details: + result["details"] = details + results[template_key] = result summary["validated"] += 1 return @@ -4340,7 +4440,7 @@ def check_regex(key, pattern, flags=0): anidb_data = anidb_settings.get("anidb", {}) if isinstance(anidb_settings, dict) else {} anidb_enabled = helpers.booler(anidb_data.get("enable")) if isinstance(anidb_data, dict) else False if anidb_enabled: - update_section_validation("100-anidb", "anidb", True) + update_section_validation("100-anidb", "anidb", True, details="Version N/A") else: skip_section_validation("100-anidb", "anidb", reason="disabled") @@ -4380,7 +4480,7 @@ def check_regex(key, pattern, flags=0): timeout=10, ) if response.status_code == 200: - update_section_validation("130-trakt", "trakt", True) + update_section_validation("130-trakt", "trakt", True, details="Version v2") elif response.status_code == 423: update_section_validation("130-trakt", "trakt", False, reason="account_locked") elif response.status_code in (401, 403): @@ -4405,7 +4505,7 @@ def check_regex(key, pattern, flags=0): timeout=10, ) if response.status_code == 200: - update_section_validation("140-mal", "mal", True) + update_section_validation("140-mal", "mal", True, details="Version v2") elif response.status_code in (401, 403): update_section_validation("140-mal", "mal", False, reason="token_invalid") else: diff --git a/static/local-js/010-plex.js b/static/local-js/010-plex.js index 3edf7160..b77b73dc 100644 --- a/static/local-js/010-plex.js +++ b/static/local-js/010-plex.js @@ -119,7 +119,11 @@ document.getElementById('validateButton').addEventListener('click', function () if (validatedAtInput) validatedAtInput.value = new Date().toISOString() refreshValidationCallout() - statusMessage.textContent = 'Plex server validated successfully!' + let successMessage = 'Plex server validated successfully!' + if (data.plex_version) { + successMessage += ` Version: ${data.plex_version}` + } + statusMessage.textContent = successMessage statusMessage.style.color = '#75b798' const hiddenSection = document.getElementById('hidden') hiddenSection.style.display = 'block' diff --git a/static/local-js/020-tmdb.js b/static/local-js/020-tmdb.js index 0bd5a755..87f261e4 100644 --- a/static/local-js/020-tmdb.js +++ b/static/local-js/020-tmdb.js @@ -98,7 +98,11 @@ document.addEventListener('DOMContentLoaded', function () { tmdbValidatedInput.value = 'true' if (tmdbValidatedAtInput) tmdbValidatedAtInput.value = new Date().toISOString() refreshValidationCallout() - statusMessage.textContent = 'API key is valid!' + let successMessage = 'API key is valid!' + if (data.tmdb_version) { + successMessage += ` Version: ${data.tmdb_version}` + } + statusMessage.textContent = successMessage statusMessage.style.color = '#75b798' // Green } else { tmdbValidatedInput.value = 'false' diff --git a/static/local-js/030-tautulli.js b/static/local-js/030-tautulli.js index a58d01d9..106000f5 100644 --- a/static/local-js/030-tautulli.js +++ b/static/local-js/030-tautulli.js @@ -80,7 +80,11 @@ document.getElementById('validateButton').addEventListener('click', function () document.getElementById('tautulli_validated').value = 'true' if (validatedAtInput) validatedAtInput.value = new Date().toISOString() refreshValidationCallout() - statusMessage.textContent = 'Tautulli server validated successfully!' + let successMessage = 'Tautulli server validated successfully!' + if (data.tautulli_version) { + successMessage += ` Version: ${data.tautulli_version}` + } + statusMessage.textContent = successMessage statusMessage.style.color = '#75b798' document.getElementById('validateButton').disabled = true } else { diff --git a/static/local-js/040-github.js b/static/local-js/040-github.js index 4fa9d735..df0ab704 100644 --- a/static/local-js/040-github.js +++ b/static/local-js/040-github.js @@ -67,7 +67,11 @@ document.getElementById('validateButton').addEventListener('click', function () .then(data => { if (data.valid) { hideSpinner('validate') - statusMessage.textContent = data.message + let successMessage = data.message || 'GitHub token is valid.' + if (data.github_version) { + successMessage += ` Version: ${data.github_version}` + } + statusMessage.textContent = successMessage statusMessage.style.color = '#75b798' document.getElementById('validateButton').disabled = true document.getElementById('github_validated').value = 'true' diff --git a/static/local-js/050-omdb.js b/static/local-js/050-omdb.js index 47674332..891d2cb3 100644 --- a/static/local-js/050-omdb.js +++ b/static/local-js/050-omdb.js @@ -70,7 +70,11 @@ document.getElementById('validateButton').addEventListener('click', function () document.getElementById('omdb_validated').value = 'true' if (validatedAtInput) validatedAtInput.value = new Date().toISOString() refreshValidationCallout() - statusMessage.textContent = 'OMDb API key is valid.' + let successMessage = 'OMDb API key is valid.' + if (data.omdb_version) { + successMessage += ` Version: ${data.omdb_version}` + } + statusMessage.textContent = successMessage statusMessage.style.color = '#75b798' document.getElementById('validateButton').disabled = true } else { diff --git a/static/local-js/060-mdblist.js b/static/local-js/060-mdblist.js index dbd17100..2fa44819 100644 --- a/static/local-js/060-mdblist.js +++ b/static/local-js/060-mdblist.js @@ -63,7 +63,11 @@ $(document).ready(function () { document.getElementById('mdblist_validated').value = 'true' if (validatedAtInput) validatedAtInput.value = new Date().toISOString() refreshValidationCallout() - statusMessage.textContent = 'API key is valid!' + let successMessage = 'API key is valid!' + if (data.mdblist_version) { + successMessage += ` Version: ${data.mdblist_version}` + } + statusMessage.textContent = successMessage statusMessage.style.color = '#75b798' validateButton.disabled = true } else { diff --git a/static/local-js/070-notifiarr.js b/static/local-js/070-notifiarr.js index 6e2c5b00..a5668c86 100644 --- a/static/local-js/070-notifiarr.js +++ b/static/local-js/070-notifiarr.js @@ -48,16 +48,20 @@ async function validateNotifiarrApikey (apikey) { body: JSON.stringify({ notifiarr_apikey: apikey }) }) - if (response.ok) { - hideSpinner('validate') - const data = await response.json() - return data.valid - } else { - hideSpinner('validate') - const errorData = await response.json() - console.error('Error validating Notifiarr apikey:', errorData.message) - return false + hideSpinner('validate') + let data = {} + try { + data = await response.json() + } catch (err) { + data = {} + } + + if (!response.ok) { + console.error('Error validating Notifiarr apikey:', data.message) + return { valid: false, error: data.message || 'Invalid Notifiarr API key.' } } + + return data } document.getElementById('validateButton').addEventListener('click', function () { @@ -73,19 +77,23 @@ document.getElementById('validateButton').addEventListener('click', function () validateButton.disabled = true - validateNotifiarrApikey(apiKey).then(isValid => { + validateNotifiarrApikey(apiKey).then((data) => { + const isValid = data && data.valid if (isValid) { document.getElementById('notifiarr_validated').value = 'true' if (validatedAtInput) validatedAtInput.value = new Date().toISOString() refreshValidationCallout() - statusMessage.textContent = 'Notifiarr API key is valid.' + let successMessage = 'Notifiarr API key is valid.' + const versionLabel = (data.notifiarr_version && String(data.notifiarr_version).trim()) || 'N/A' + successMessage += ` Version: ${versionLabel}` + statusMessage.textContent = successMessage statusMessage.style.color = '#75b798' validateButton.disabled = true } else { document.getElementById('notifiarr_validated').value = 'false' if (validatedAtInput) validatedAtInput.value = '' refreshValidationCallout() - statusMessage.textContent = 'Notifiarr API key is invalid.' + statusMessage.textContent = data.error || 'Notifiarr API key is invalid.' statusMessage.style.color = '#ea868f' validateButton.disabled = false } diff --git a/static/local-js/080-gotify.js b/static/local-js/080-gotify.js index 214aef6a..fcdcbe18 100644 --- a/static/local-js/080-gotify.js +++ b/static/local-js/080-gotify.js @@ -85,7 +85,11 @@ document.getElementById('validateButton').addEventListener('click', function () document.getElementById('gotify_validated').value = 'true' if (validatedAtInput) validatedAtInput.value = new Date().toISOString() refreshValidationCallout() - statusMessage.textContent = 'Gotify credentials validated successfully!' + let successMessage = 'Gotify credentials validated successfully!' + if (data.gotify_version) { + successMessage += ` Version: ${data.gotify_version}` + } + statusMessage.textContent = successMessage statusMessage.style.color = '#75b798' } else { hideSpinner('validate') diff --git a/static/local-js/085-ntfy.js b/static/local-js/085-ntfy.js index 9005d533..ee51fe2b 100644 --- a/static/local-js/085-ntfy.js +++ b/static/local-js/085-ntfy.js @@ -96,7 +96,10 @@ document.getElementById('validateButton').addEventListener('click', function () document.getElementById('ntfy_validated').value = 'true' if (validatedAtInput) validatedAtInput.value = new Date().toISOString() refreshValidationCallout() - statusMessage.textContent = 'ntfy credentials validated successfully! Ensure you are subscribed to topic to see test message.' + let successMessage = 'ntfy credentials validated successfully! Ensure you are subscribed to topic to see test message.' + const versionLabel = (data.ntfy_version && String(data.ntfy_version).trim()) || 'N/A' + successMessage += ` Version: ${versionLabel}` + statusMessage.textContent = successMessage statusMessage.style.color = '#75b798' } else { hideSpinner('validate') diff --git a/static/local-js/110-radarr.js b/static/local-js/110-radarr.js index 521194f9..59d56d25 100644 --- a/static/local-js/110-radarr.js +++ b/static/local-js/110-radarr.js @@ -170,7 +170,11 @@ function validateRadarrApi () { document.getElementById('radarr_validated').value = 'true' if (validatedAtInput) validatedAtInput.value = new Date().toISOString() refreshValidationCallout() - statusMessage.textContent = 'Radarr API key is valid.' + let successMessage = 'Radarr API key is valid.' + if (data.radarr_version) { + successMessage += ` Version: ${data.radarr_version}` + } + statusMessage.textContent = successMessage statusMessage.style.color = '#75b798' statusMessage.style.display = 'block' document.getElementById('validateButton').disabled = true diff --git a/static/local-js/120-sonarr.js b/static/local-js/120-sonarr.js index 39f069fb..f2c53866 100644 --- a/static/local-js/120-sonarr.js +++ b/static/local-js/120-sonarr.js @@ -87,7 +87,11 @@ function validateSonarrApi () { document.getElementById('sonarr_validated').value = 'true' if (validatedAtInput) validatedAtInput.value = new Date().toISOString() refreshValidationCallout() - statusMessage.textContent = 'Sonarr API key is valid.' + let successMessage = 'Sonarr API key is valid.' + if (data.sonarr_version) { + successMessage += ` Version: ${data.sonarr_version}` + } + statusMessage.textContent = successMessage statusMessage.style.color = '#75b798' statusMessage.style.display = 'block' document.getElementById('validateButton').disabled = true diff --git a/static/local-js/130-trakt.js b/static/local-js/130-trakt.js index 46667050..fa21d249 100644 --- a/static/local-js/130-trakt.js +++ b/static/local-js/130-trakt.js @@ -145,7 +145,11 @@ document.getElementById('validate_trakt_pin').addEventListener('click', function document.getElementById('trakt_validated').value = 'true' if (validatedAtInput) validatedAtInput.value = new Date().toISOString() refreshValidationCallout() - statusMessage.textContent = 'Trakt credentials validated successfully!' + let successMessage = 'Trakt credentials validated successfully!' + if (data.trakt_version) { + successMessage += ` Version: ${data.trakt_version}` + } + statusMessage.textContent = successMessage statusMessage.style.color = '#75b798' document.getElementById('access_token').value = data.trakt_authorization_access_token document.getElementById('token_type').value = data.trakt_authorization_token_type @@ -230,7 +234,11 @@ if (traktCheckButton) { document.getElementById('trakt_validated').value = 'true' if (validatedAtInput) validatedAtInput.value = new Date().toISOString() refreshValidationCallout() - statusMessage.textContent = 'Trakt token is valid.' + let successMessage = 'Trakt token is valid.' + if (data.trakt_version) { + successMessage += ` Version: ${data.trakt_version}` + } + statusMessage.textContent = successMessage statusMessage.style.color = '#75b798' } else { document.getElementById('trakt_validated').value = 'false' diff --git a/static/local-js/140-mal.js b/static/local-js/140-mal.js index 2ed2592c..132823c1 100644 --- a/static/local-js/140-mal.js +++ b/static/local-js/140-mal.js @@ -148,7 +148,11 @@ document.getElementById('validate_mal_url').addEventListener('click', function ( document.getElementById('mal_validated').value = 'true' if (validatedAtInput) validatedAtInput.value = new Date().toISOString() refreshValidationCallout() - statusMessage.textContent = 'MyAnimeList credentials validated successfully!' + let successMessage = 'MyAnimeList credentials validated successfully!' + if (data.mal_version) { + successMessage += ` Version: ${data.mal_version}` + } + statusMessage.textContent = successMessage statusMessage.style.color = '#75b798' document.getElementById('access_token').value = data.mal_authorization_access_token document.getElementById('token_type').value = data.mal_authorization_token_type @@ -206,7 +210,11 @@ if (malCheckButton) { document.getElementById('mal_validated').value = 'true' if (validatedAtInput) validatedAtInput.value = new Date().toISOString() refreshValidationCallout() - statusMessage.textContent = 'MyAnimeList token is valid.' + let successMessage = 'MyAnimeList token is valid.' + if (data.mal_version) { + successMessage += ` Version: ${data.mal_version}` + } + statusMessage.textContent = successMessage statusMessage.style.color = '#75b798' } else { document.getElementById('mal_validated').value = 'false' diff --git a/static/local-js/900-final.js b/static/local-js/900-final.js index 695b6b40..ded4a0b5 100644 --- a/static/local-js/900-final.js +++ b/static/local-js/900-final.js @@ -1548,7 +1548,15 @@ $(document).ready(function () { function formatValidationResult (status, reason, details) { if (!status) return '' const label = status.charAt(0).toUpperCase() + status.slice(1) - if (!reason) return label + if (!reason) { + if (Array.isArray(details) && details.length) { + return `${label}: ${details.join(', ')}` + } + if (details) { + return `${label}: ${details}` + } + return label + } const pretty = validationReasonLabels[reason] || reason.replace(/_/g, ' ') if (Array.isArray(details) && details.length) { return `${label}: ${pretty}: ${details.join(', ')}`