refactor(clients): reorganize client modules into clients package

- Move auth, user, and music clients into new clients package
- Update imports to use new module paths
- Add __init__.py to expose clients from package
- Clean up code formatting and imports
- Add pre-commit configuration for code quality checks
This commit is contained in:
2025-06-07 06:57:01 -07:00
parent eac722e37f
commit 161a4d42d6
14 changed files with 191 additions and 128 deletions

2
.gitignore vendored
View File

@ -170,4 +170,4 @@ cython_debug/
venv/*
Documents.md
test.py
test.py

35
.pre-commit-config.yaml Normal file
View File

@ -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"]

View File

@ -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.
**Note**: This is an unofficial client library. songbox is a trademark of its respective owners.

View File

@ -1,2 +1,2 @@
boto3==1.38.32
httpx==0.28.1
boto3==1.38.32

View File

@ -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",
},
)
)

View File

@ -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",
]
]

View File

@ -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 <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 <OTP> --verify-id {verify_id}")
print(
f"Use this command to verify: songbox auth verify --otp <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()
main()

View File

@ -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()
self.close()

View File

@ -0,0 +1,6 @@
from .music import MusicClient
from .user import UserClient
from .auth import AuthClient
__all__ = ["AuthClient", "UserClient", "MusicClient"]

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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
pass