diff --git a/.gitignore b/.gitignore index 0bbadef..074f134 100644 --- a/.gitignore +++ b/.gitignore @@ -170,4 +170,4 @@ cython_debug/ venv/* Documents.md -test.py \ No newline at end of file +test.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ee235d7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,35 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-added-large-files + - id: check-ast + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-json + - id: check-merge-conflict + - id: check-symlinks + - id: detect-private-key + - id: file-contents-sorter + - id: mixed-line-ending + - id: name-tests-test + - id: pretty-format-json + args: [--autofix] + - id: debug-statements + - id: check-docstring-first + - id: requirements-txt-fixer + + - repo: https://github.com/psf/black + rev: 24.2.0 + hooks: + - id: black + language_version: python3 + + - repo: https://github.com/PyCQA/isort + rev: 6.0.1 + hooks: + - id: isort + args: ["--profile", "black"] diff --git a/README.md b/README.md index 9217299..937ff9f 100644 --- a/README.md +++ b/README.md @@ -204,18 +204,18 @@ try: # Step 1: Initiate login verify_id = client.auth.login("960", "7777777") print(f"OTP sent! Verification ID: {verify_id}") - + # Step 2: Get OTP from user otp = input("Enter OTP: ") - + # Step 3: Verify OTP and get token token = client.auth.verify_otp(otp, verify_id) print(f"Login successful! Token: {token}") - + # Step 4: Use the API user_info = client.user.get_me() print(f"Welcome, {user_info['username']}!") - + except AuthenticationError as e: print(f"Authentication failed: {e}") finally: @@ -232,7 +232,7 @@ for category in results: print(f"\n{category['title']}:") for item in category['items'][:3]: # Show top 3 print(f" {item['heading']} - {item['sub_heading']}") - + # Get detailed info if it's a song if category['title'] == 'Songs': try: @@ -281,4 +281,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file --- -**Note**: This is an unofficial client library. songbox is a trademark of its respective owners. \ No newline at end of file +**Note**: This is an unofficial client library. songbox is a trademark of its respective owners. diff --git a/example.py b/examples/download_songs.py similarity index 100% rename from example.py rename to examples/download_songs.py diff --git a/requirements.txt b/requirements.txt index 73dfdfa..d086f1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ +boto3==1.38.32 httpx==0.28.1 -boto3==1.38.32 \ No newline at end of file diff --git a/setup.py b/setup.py index b63f8e4..cf1a736 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ """Setup script for songbox API Client Library""" -from setuptools import setup, find_packages +from setuptools import find_packages, setup with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() @@ -8,10 +8,7 @@ with open("README.md", "r", encoding="utf-8") as fh: # with open("songbox/requirements.txt", "r", encoding="utf-8") as fh: # requirements = [line.strip() for line in fh if line.strip() and not line.startswith("#")] -requirements = [ - "httpx==0.28.1", - "boto3==1.38.32" -] +requirements = ["httpx==0.28.1", "boto3==1.38.32"] setup( name="songbox", @@ -59,4 +56,4 @@ setup( "Source": "https://git.cubable.date/CustomIcon/songbox", "Documentation": "https://git.cubable.date/CustomIcon/songbox/src/branch/master/README.md", }, -) \ No newline at end of file +) diff --git a/songbox/__init__.py b/songbox/__init__.py index 030b437..82247a0 100644 --- a/songbox/__init__.py +++ b/songbox/__init__.py @@ -4,15 +4,13 @@ A Python client library for the songbox music streaming API. """ from .client import SongboxClient -from .auth import AuthClient -from .user import UserClient -from .music import MusicClient +from .clients import AuthClient, MusicClient, UserClient from .exceptions import ( - songboxError, - AuthenticationError, APIError, + AuthenticationError, + NotFoundError, RateLimitError, - NotFoundError + songboxError, ) __version__ = "1.0.0" @@ -29,4 +27,4 @@ __all__ = [ "APIError", "RateLimitError", "NotFoundError", -] \ No newline at end of file +] diff --git a/songbox/__main__.py b/songbox/__main__.py index ebf7f6b..88efee2 100644 --- a/songbox/__main__.py +++ b/songbox/__main__.py @@ -3,9 +3,9 @@ import argparse import json import sys -from typing import Optional + from .client import SongboxClient -from .exceptions import songboxError, AuthenticationError +from .exceptions import AuthenticationError, songboxError def create_parser() -> argparse.ArgumentParser: @@ -20,98 +20,110 @@ Examples: songbox auth login --country-code 960 --mobile 7777777 songbox auth verify --otp 123456 --verify-id songbox user me - + # Search for music songbox music search "huttaa" - + # Get album information songbox music album 808 - + # Download a song songbox music download "https://example.com/song.mp3" songbox music download "https://example.com/song.mp3" --path ./my_music - + # Get user's following list songbox user following -""" +""", ) - + parser.add_argument( "--token", - help="Authentication token (can also be set via songbox_TOKEN env var)" + help="Authentication token (can also be set via songbox_TOKEN env var)", ) - + parser.add_argument( "--base-url", default="https://lavafoshi.online", - help="Base URL for the API (default: https://lavafoshi.online)" + help="Base URL for the API (default: https://lavafoshi.online)", ) - + parser.add_argument( "--format", choices=["json", "table", "simple"], default="simple", - help="Output format (default: simple)" + help="Output format (default: simple)", ) - + subparsers = parser.add_subparsers(dest="command", help="Available commands") - + # Auth commands auth_parser = subparsers.add_parser("auth", help="Authentication commands") auth_subparsers = auth_parser.add_subparsers(dest="auth_command") - + # Login command - login_parser = auth_subparsers.add_parser("login", help="Initiate login with mobile number") - login_parser.add_argument("--country-code", required=True, help="Country code (e.g., 960)") + login_parser = auth_subparsers.add_parser( + "login", help="Initiate login with mobile number" + ) + login_parser.add_argument( + "--country-code", required=True, help="Country code (e.g., 960)" + ) login_parser.add_argument("--mobile", required=True, help="Mobile number") - + # Verify OTP command - verify_parser = auth_subparsers.add_parser("verify", help="Verify OTP and get token") + verify_parser = auth_subparsers.add_parser( + "verify", help="Verify OTP and get token" + ) verify_parser.add_argument("--otp", required=True, help="OTP code") - verify_parser.add_argument("--verify-id", required=True, help="Verification ID from login") - + verify_parser.add_argument( + "--verify-id", required=True, help="Verification ID from login" + ) + # Refresh token command auth_subparsers.add_parser("refresh", help="Refresh authentication token") - + # Logout command auth_subparsers.add_parser("logout", help="Logout (clear token)") - + # User commands user_parser = subparsers.add_parser("user", help="User commands") user_subparsers = user_parser.add_subparsers(dest="user_command") - + user_subparsers.add_parser("me", help="Get current user information") user_subparsers.add_parser("following", help="Get following list") - + # Music commands music_parser = subparsers.add_parser("music", help="Music commands") music_subparsers = music_parser.add_subparsers(dest="music_command") - + # Search command search_parser = music_subparsers.add_parser("search", help="Search for music") search_parser.add_argument("query", help="Search query") - + # Album command album_parser = music_subparsers.add_parser("album", help="Get album information") album_parser.add_argument("album_id", help="Album ID") - + # Song command song_parser = music_subparsers.add_parser("song", help="Get song information") song_parser.add_argument("song_id", help="Song ID") - + # Artist command artist_parser = music_subparsers.add_parser("artist", help="Get artist information") artist_parser.add_argument("artist_id", help="Artist ID") - + # Trending command trending_parser = music_subparsers.add_parser("trending", help="Get trending music") trending_parser.add_argument("--limit", type=int, help="Limit number of results") - + # Download command download_parser = music_subparsers.add_parser("download", help="Download a song") - download_parser.add_argument("song_url", help="Song URL to download (e.g., from song['url_original'])") - download_parser.add_argument("--path", help="Download directory path (default: ./downloads)") - + download_parser.add_argument( + "song_url", help="Song URL to download (e.g., from song['url_original'])" + ) + download_parser.add_argument( + "--path", help="Download directory path (default: ./downloads)" + ) + return parser @@ -159,21 +171,25 @@ def handle_auth_commands(client: SongboxClient, args) -> None: try: verify_id = client.auth.login(args.country_code, args.mobile) print(f"OTP sent! Verification ID: {verify_id}") - print(f"Use this command to verify: songbox auth verify --otp --verify-id {verify_id}") + print( + f"Use this command to verify: songbox auth verify --otp --verify-id {verify_id}" + ) except Exception as e: print(f"Login failed: {e}", file=sys.stderr) sys.exit(1) - + elif args.auth_command == "verify": try: token = client.auth.verify_otp(args.otp, args.verify_id) print(f"Authentication successful!") print(f"Token: {token}") - print(f"Save this token and use it with --token flag or songbox_TOKEN env var") + print( + f"Save this token and use it with --token flag or songbox_TOKEN env var" + ) except Exception as e: print(f"Verification failed: {e}", file=sys.stderr) sys.exit(1) - + elif args.auth_command == "refresh": try: new_token = client.auth.refresh_token() @@ -182,7 +198,7 @@ def handle_auth_commands(client: SongboxClient, args) -> None: except Exception as e: print(f"Token refresh failed: {e}", file=sys.stderr) sys.exit(1) - + elif args.auth_command == "logout": client.auth.logout() print("Logged out successfully!") @@ -197,7 +213,7 @@ def handle_user_commands(client: SongboxClient, args) -> None: except Exception as e: print(f"Failed to get user info: {e}", file=sys.stderr) sys.exit(1) - + elif args.user_command == "following": try: following = client.user.get_following() @@ -222,7 +238,7 @@ def handle_music_commands(client: SongboxClient, args) -> None: except Exception as e: print(f"Search failed: {e}", file=sys.stderr) sys.exit(1) - + elif args.music_command == "album": try: album = client.music.get_album(args.album_id) @@ -230,7 +246,7 @@ def handle_music_commands(client: SongboxClient, args) -> None: except Exception as e: print(f"Failed to get album: {e}", file=sys.stderr) sys.exit(1) - + elif args.music_command == "song": try: song = client.music.get_song(args.song_id) @@ -238,7 +254,7 @@ def handle_music_commands(client: SongboxClient, args) -> None: except Exception as e: print(f"Failed to get song: {e}", file=sys.stderr) sys.exit(1) - + elif args.music_command == "artist": try: artist = client.music.get_artist(args.artist_id) @@ -246,7 +262,7 @@ def handle_music_commands(client: SongboxClient, args) -> None: except Exception as e: print(f"Failed to get artist: {e}", file=sys.stderr) sys.exit(1) - + elif args.music_command == "download": try: file_path = client.music.download_song(args.song_url, args.path) @@ -257,42 +273,48 @@ def handle_music_commands(client: SongboxClient, args) -> None: sys.exit(1) - def main(): """Main CLI entry point.""" parser = create_parser() args = parser.parse_args() - + if not args.command: parser.print_help() sys.exit(1) - + # Get token from args or environment import os + token = args.token or os.getenv("songbox_TOKEN") - + # Create client try: client = SongboxClient(base_url=args.base_url) - + # Set token if provided if token: client.set_token(token) - + # Handle commands if args.command == "auth": handle_auth_commands(client, args) elif args.command == "user": if not client.is_authenticated: - print("Authentication required. Please login first or provide a token.", file=sys.stderr) + print( + "Authentication required. Please login first or provide a token.", + file=sys.stderr, + ) sys.exit(1) handle_user_commands(client, args) elif args.command == "music": if not client.is_authenticated: - print("Authentication required. Please login first or provide a token.", file=sys.stderr) + print( + "Authentication required. Please login first or provide a token.", + file=sys.stderr, + ) sys.exit(1) handle_music_commands(client, args) - + except KeyboardInterrupt: print("\nOperation cancelled.", file=sys.stderr) sys.exit(1) @@ -300,9 +322,9 @@ def main(): print(f"Error: {e}", file=sys.stderr) sys.exit(1) finally: - if 'client' in locals(): + if "client" in locals(): client.close() if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/songbox/client.py b/songbox/client.py index fc270f0..3ca1f87 100644 --- a/songbox/client.py +++ b/songbox/client.py @@ -1,24 +1,24 @@ """Main songbox API Client""" +from typing import Any, Dict, Optional + import httpx -from typing import Optional, Dict, Any -from .auth import AuthClient -from .user import UserClient -from .music import MusicClient -from .exceptions import songboxError, APIError + +from .clients import AuthClient, MusicClient, UserClient +from .exceptions import APIError, songboxError class SongboxClient: """Main client for interacting with the songbox API. - + This client provides access to all songbox API endpoints through modular sub-clients for authentication, user management, and music. - + Args: base_url: The base URL for the songbox API timeout: Request timeout in seconds (default: 30) headers: Additional headers to include with requests - + Example: >>> client = SongboxClient() >>> # Authenticate @@ -29,62 +29,62 @@ class SongboxClient: >>> user_info = client.user.get_me() >>> print(user_info) """ - + def __init__( self, base_url: str = "https://lavafoshi.online", timeout: float = 30.0, - headers: Optional[Dict[str, str]] = None + headers: Optional[Dict[str, str]] = None, ): - self.base_url = base_url.rstrip('/') + self.base_url = base_url.rstrip("/") self._token: Optional[str] = None - + # Default headers default_headers = { "User-Agent": "songbox/2.1.4 (songbox; Python) httpx", "Accept": "*/*", "Accept-Language": "en-US;q=1.0", "Accept-Encoding": "gzip;q=1.0, compress;q=0.5", - "Connection": "keep-alive" + "Connection": "keep-alive", } - + if headers: default_headers.update(headers) self._http_client = httpx.Client( base_url=self.base_url, timeout=timeout, headers=default_headers, - follow_redirects=True + follow_redirects=True, ) self.auth = AuthClient(self) self.user = UserClient(self) self.music = MusicClient(self) - + def set_token(self, token: str) -> None: """Set the authentication token for API requests. - + Args: token: JWT token obtained from authentication """ self._token = token self._http_client.headers["Authorization"] = f"Bearer {token}" - + def clear_token(self) -> None: """Clear the authentication token.""" self._token = None if "Authorization" in self._http_client.headers: del self._http_client.headers["Authorization"] - + @property def token(self) -> Optional[str]: """Get the current authentication token.""" return self._token - + @property def is_authenticated(self) -> bool: """Check if the client is authenticated.""" return self._token is not None - + def request( self, method: str, @@ -93,10 +93,10 @@ class SongboxClient: data: Optional[Dict[str, Any]] = None, json: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, - require_auth: bool = True + require_auth: bool = True, ) -> Dict[str, Any]: """Make an HTTP request to the songbox API. - + Args: method: HTTP method (GET, POST, etc.) endpoint: API endpoint (without base URL) @@ -105,19 +105,19 @@ class SongboxClient: json: JSON data for POST requests headers: Additional headers require_auth: Whether authentication is required - + Returns: JSON response data - + Raises: songboxError: If authentication is required but not provided APIError: If the API returns an error response """ if require_auth and not self.is_authenticated: raise songboxError("Authentication required for this endpoint") - - url = endpoint if endpoint.startswith('http') else f"/api{endpoint}" - + + url = endpoint if endpoint.startswith("http") else f"/api{endpoint}" + try: response = self._http_client.request( method=method, @@ -125,34 +125,34 @@ class SongboxClient: params=params, data=data, json=json, - headers=headers + headers=headers, ) - + response.raise_for_status() - + try: return response.json() except ValueError: return {} - + except httpx.HTTPStatusError as e: try: error_data = e.response.json() - message = error_data.get('message', f'HTTP {e.response.status_code}') + message = error_data.get("message", f"HTTP {e.response.status_code}") except ValueError: - message = f'HTTP {e.response.status_code}' - + message = f"HTTP {e.response.status_code}" + raise APIError(message, status_code=e.response.status_code) - + except httpx.RequestError as e: raise songboxError(f"Request failed: {str(e)}") - + def close(self) -> None: """Close the HTTP client.""" self._http_client.close() - + def __enter__(self): return self - + def __exit__(self, exc_type, exc_val, exc_tb): - self.close() \ No newline at end of file + self.close() diff --git a/songbox/clients/__init__.py b/songbox/clients/__init__.py new file mode 100644 index 0000000..f9f7f1d --- /dev/null +++ b/songbox/clients/__init__.py @@ -0,0 +1,6 @@ +from .music import MusicClient +from .user import UserClient +from .auth import AuthClient + + +__all__ = ["AuthClient", "UserClient", "MusicClient"] \ No newline at end of file diff --git a/songbox/auth.py b/songbox/clients/auth.py similarity index 98% rename from songbox/auth.py rename to songbox/clients/auth.py index ba3905f..0391011 100644 --- a/songbox/auth.py +++ b/songbox/clients/auth.py @@ -1,7 +1,7 @@ """Authentication client for songbox API""" from typing import Dict, Any, Optional -from .exceptions import AuthenticationError, ValidationError +from ..exceptions import AuthenticationError, ValidationError class AuthClient: diff --git a/songbox/music.py b/songbox/clients/music.py similarity index 99% rename from songbox/music.py rename to songbox/clients/music.py index 46cf0cc..1264984 100644 --- a/songbox/music.py +++ b/songbox/clients/music.py @@ -5,7 +5,7 @@ import httpx from pathlib import Path from urllib.parse import quote from typing import Dict, Any, List, Optional -from .exceptions import AuthenticationError, NotFoundError, ValidationError +from ..exceptions import AuthenticationError, NotFoundError, ValidationError class MusicClient: diff --git a/songbox/user.py b/songbox/clients/user.py similarity index 99% rename from songbox/user.py rename to songbox/clients/user.py index 3d8de0f..8cac19e 100644 --- a/songbox/user.py +++ b/songbox/clients/user.py @@ -1,7 +1,7 @@ """User client for songbox API""" from typing import Dict, Any, List, Optional -from .exceptions import AuthenticationError, NotFoundError +from ..exceptions import AuthenticationError, NotFoundError class UserClient: diff --git a/songbox/exceptions.py b/songbox/exceptions.py index 1c8f598..524976b 100644 --- a/songbox/exceptions.py +++ b/songbox/exceptions.py @@ -5,29 +5,30 @@ from typing import Optional class songboxError(Exception): """Base exception for all songbox API errors.""" + pass class APIError(songboxError): """Exception raised when the API returns an error response. - + Args: message: Error message status_code: HTTP status code response_data: Raw response data from the API """ - + def __init__( - self, - message: str, - status_code: Optional[int] = None, - response_data: Optional[dict] = None + self, + message: str, + status_code: Optional[int] = None, + response_data: Optional[dict] = None, ): super().__init__(message) self.message = message self.status_code = status_code self.response_data = response_data or {} - + def __str__(self) -> str: if self.status_code: return f"API Error {self.status_code}: {self.message}" @@ -36,19 +37,23 @@ class APIError(songboxError): class AuthenticationError(songboxError): """Exception raised for authentication-related errors.""" + pass class RateLimitError(APIError): """Exception raised when rate limit is exceeded.""" + pass class NotFoundError(APIError): """Exception raised when a resource is not found (404).""" + pass class ValidationError(songboxError): """Exception raised for input validation errors.""" - pass \ No newline at end of file + + pass