commit c3e6217306fe39fe641ad412822945ec804d60b2 Author: Xoconoch Date: Thu Jun 5 14:48:20 2025 -0600 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb9df04 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.venv \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f41631 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Spotizerr Authentication Utility + +A command-line tool to help you authenticate and register a Spotify account with your Spotizerr instance. This utility simplifies the process by programmatically capturing Spotify session credentials and posting them to your Spotizerr backend. + +## Features + +- **Interactive Setup**: Guides you through the process of connecting to your Spotizerr instance. +- **API Credential Check**: Automatically checks if your Spotizerr instance has the required Spotify API `client_id` and `client_secret` and prompts you to add them if they are missing. +- **Spotify Connect Integration**: Uses `librespot-spotizerr` to create a temporary Spotify Connect device, allowing you to capture credentials securely by simply playing a song. +- **Colored Output**: Uses color-coded terminal output to highlight important information and make the process easier to follow. +- **Clean Exit**: Gracefully handles interruptions and ensures a clean shutdown. + +## Prerequisites + +- Python 3.7+ +- A running instance of [Spotizerr](https://github.com/Xoconoch/spotizerr). +- Spotify `client_id` and `client_secret`. You can get these by creating an application on the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard). + +## Installation + +1. **Clone the repository or download the files:** + ```bash + git clone + cd spotizerr-auth + ``` + +2. **Install the required Python packages:** + It is recommended to use a virtual environment. + ```bash + python -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt + ``` + +## Usage + +Run the script from your terminal: + +```bash +python spotizerr-auth.py +``` + +The script will guide you through the following steps: + +1. **Enter Spotizerr URL**: You'll be prompted for the base URL of your Spotizerr instance. You can press Enter to use the default (`http://localhost:7171`). + +2. **Configure API Credentials**: The script checks if your Spotizerr instance is configured with a Spotify `client_id` and `client_secret`. + - If they are missing, it will ask if you want to configure them. + - Select `y` and enter your credentials when prompted. This is a one-time setup. + +3. **Enter Account Details**: + - Provide a **name for the account** to identify it in Spotizerr. + - Enter your two-letter **Spotify region code** (e.g., `US`, `DE`, `MX`). + +4. **Authenticate via Spotify Connect**: + - The utility will start a Spotify Connect device on your network (e.g., `librespot-spotizerr`). + - Open Spotify on any device (phone, desktop), start playing a track, and use the "Connect to a device" feature to **transfer playback to the new device**. + - Once you transfer playback, the script captures the session, creates a `credentials.json` file, and shuts down the Connect server. + +5. **Register with Spotizerr**: The script automatically sends the captured credentials to your Spotizerr instance, creating or updating the account. + +6. **Cleanup**: Finally, it will ask if you want to delete the `credentials.json` file. It's recommended to do so for security. + +After these steps, your Spotify account will be registered in Spotizerr and ready to use. + +## How It Works + +The script uses `librespot-spotizerr`'s Zeroconf implementation to advertise a Spotify Connect device on the local network. When you transfer playback to this device, `librespot-spotizerr` handles the authentication with Spotify's servers and stores the session details (including the necessary refresh token) in a local `credentials.json` file. + +Once this file is created, the script reads it and makes a `POST` request to the Spotizerr API endpoint `/api/credentials/spotify/{accountName}`. The request body is a JSON object containing the user's region and the contents of `credentials.json`, which Spotizerr then stores for future use. + +The initial check for `client_id` and `client_secret` ensures that Spotizerr has the necessary global API credentials to perform metadata lookups and other API-dependent tasks. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a78e775 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +librespot-spotizerr +requests \ No newline at end of file diff --git a/spotizerr-auth.py b/spotizerr-auth.py new file mode 100644 index 0000000..159ec16 --- /dev/null +++ b/spotizerr-auth.py @@ -0,0 +1,210 @@ +import time +import logging +import pathlib +import json +import requests +import sys + +class Colors: + BLUE = '\033[94m' + CYAN = '\033[96m' + GREEN = '\033[92m' + YELLOW = '\033[93m' + RED = '\033[91m' + ENDC = '\033[0m' + BOLD = '\033[1m' + +try: + from librespot.zeroconf import ZeroconfServer +except ImportError: + logging.error("librespot-spotizerr is not installed. Please install it with pip.") + logging.error("e.g. 'pip install -r requirements.txt' or 'pip install librespot-spotizerr'") + sys.exit(1) + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') + +def get_spotify_session_and_wait_for_credentials(): + """ + Starts Zeroconf server and waits for librespot to store credentials. + """ + credential_file = pathlib.Path("credentials.json") + + if credential_file.exists(): + logging.info(f"Removing existing '{credential_file}'") + try: + credential_file.unlink() + except OSError as e: + logging.error(f"Could not remove existing 'credentials.json': {e}") + sys.exit(1) + + zs = ZeroconfServer.Builder().create() + device_name = "librespot-spotizerr" + # This is a bit of a hack to get the device name, but it's useful for the user. + if hasattr(zs, '_ZeroconfServer__server') and hasattr(zs._ZeroconfServer__server, 'name'): + device_name = zs._ZeroconfServer__server.name + + logging.info(f"Spotify Connect device '{Colors.CYAN}{device_name}{Colors.ENDC}' is now available on your network.") + logging.info(f"Please open Spotify on another device, and {Colors.BOLD}transfer playback to it{Colors.ENDC}.") + logging.info("This will capture your session and save it as 'credentials.json'.") + + try: + while True: + time.sleep(1) + if credential_file.is_file() and credential_file.stat().st_size > 0: + logging.info(f"'{Colors.GREEN}credentials.json{Colors.ENDC}' has been created.") + if hasattr(zs, '_ZeroconfServer__session') and zs._ZeroconfServer__session: + try: + username = zs._ZeroconfServer__session.username() + logging.info(f"Session captured for user: {Colors.GREEN}{username}{Colors.ENDC}") + except Exception: + pass # It's ok if we can't get username + break + finally: + logging.info("Shutting down Spotify Connect server...") + zs.close() + +def check_and_configure_api_creds(base_url): + """ + Checks if Spotizerr has Spotify API credentials and prompts user to add them if missing. + """ + api_config_url = f"{base_url.rstrip('/')}/api/credentials/spotify_api_config" + logging.info("Checking Spotizerr server for Spotify API configuration...") + + try: + response = requests.get(api_config_url, timeout=10) + if response.status_code >= 400: + response.raise_for_status() + + data = response.json() + client_id = data.get("client_id") + client_secret = data.get("client_secret") + + if client_id and client_secret: + logging.info(f"{Colors.GREEN}Spotizerr API credentials are already configured.{Colors.ENDC}") + return True + + logging.warning(f"{Colors.YELLOW}Spotizerr server is missing Spotify API credentials (client_id/client_secret).{Colors.ENDC}") + logging.warning("You can get these from the Spotify Developer Dashboard: https://developer.spotify.com/dashboard") + configure_now = input(f"Do you want to configure them now? ({Colors.GREEN}y{Colors.ENDC}/{Colors.BOLD}N{Colors.ENDC}): ").lower() + + if configure_now != 'y': + logging.info("Please configure the API credentials on your Spotizerr server before proceeding.") + return False + + new_client_id = input(f"Enter your Spotify {Colors.CYAN}client_id{Colors.ENDC}: ") + new_client_secret = input(f"Enter your Spotify {Colors.CYAN}client_secret{Colors.ENDC}: ") + + if not new_client_id or not new_client_secret: + logging.error(f"{Colors.RED}Both client_id and client_secret must be provided.{Colors.ENDC}") + return False + + payload = {"client_id": new_client_id, "client_secret": new_client_secret} + headers = {"Content-Type": "application/json"} + + put_response = requests.put(api_config_url, headers=headers, json=payload, timeout=10) + put_response.raise_for_status() + + logging.info("Successfully configured Spotizerr API credentials.") + return True + + except requests.exceptions.RequestException as e: + logging.error(f"Failed to communicate with Spotizerr API at {api_config_url}: {e}") + if e.response is not None: + logging.error(f"Response status: {e.response.status_code}") + try: + logging.error(f"Response body: {e.response.json()}") + except json.JSONDecodeError: + logging.error(f"Response body: {e.response.text}") + logging.error("Please ensure your Spotizerr instance is running and accessible at the specified URL.") + return False + +def main(): + """ + Main function for the Spotizerr auth utility. + """ + try: + base_url = input("Enter the base URL of your Spotizerr instance [default: http://localhost:7171]: ") + if not base_url: + base_url = "http://localhost:7171" + logging.info(f"Using default base URL: {base_url}") + + if not base_url.startswith(('http://', 'https://')): + base_url = 'http://' + base_url + + if not check_and_configure_api_creds(base_url): + sys.exit(1) + + account_name = input("Enter a name for this Spotify account: ") + if not account_name: + logging.error("Account name cannot be empty.") + sys.exit(1) + + region = input("Enter your Spotify region (e.g., US, DE, MX). This is the 2-letter country code: ").upper() + if not region: + logging.error("Region cannot be empty.") + sys.exit(1) + + cred_file = pathlib.Path("credentials.json") + if cred_file.exists(): + overwrite = input(f"'{cred_file}' already exists. Overwrite it by connecting to Spotify? (y/N): ").lower() + if overwrite == 'y': + get_spotify_session_and_wait_for_credentials() + else: + logging.info("Using existing 'credentials.json'.") + else: + get_spotify_session_and_wait_for_credentials() + + if not cred_file.exists(): + logging.error("Failed to obtain 'credentials.json'. Exiting.") + sys.exit(1) + + try: + with open(cred_file, "r") as f: + credentials_data = json.load(f) + except (FileNotFoundError, json.JSONDecodeError) as e: + logging.error(f"Could not read or parse 'credentials.json': {e}") + sys.exit(1) + + payload = { + "region": region, + "blob_content": credentials_data + } + + api_url = f"{base_url.rstrip('/')}/api/credentials/spotify/{account_name}" + headers = {"Content-Type": "application/json"} + + logging.info(f"Registering account '{account_name}' to Spotizerr at '{api_url}'") + + try: + response = requests.post(api_url, headers=headers, json=payload) + response.raise_for_status() + logging.info("Successfully registered/updated Spotify account in Spotizerr!") + if response.text: + logging.info(f"Response from server: {response.text}") + except requests.exceptions.RequestException as e: + logging.error(f"Failed to call Spotizerr API: {e}") + if e.response is not None: + logging.error(f"Response status: {e.response.status_code}") + logging.error(f"Response body: {e.response.text}") + sys.exit(1) + finally: + cleanup = input("Do you want to delete 'credentials.json' now? (y/N): ").lower() + if cleanup == 'y': + try: + if cred_file.exists(): + cred_file.unlink() + logging.info("'credentials.json' deleted.") + except OSError as e: + logging.error(f"Error deleting 'credentials.json': {e}") + else: + logging.info("'credentials.json' not deleted.") + + sys.exit(0) + + except KeyboardInterrupt: + logging.info("\nOperation cancelled by user. Exiting.") + sys.exit(0) + + +if __name__ == "__main__": + main()