feat: add initial songbox API client library
This commit introduces the initial version of the songbox API client library, including: - Core client implementation with authentication and HTTP handling - Modular sub-clients for auth, user, and music operations - Comprehensive exception hierarchy for error handling - CLI interface with support for all API operations - Setup configuration with dependencies and package metadata - Documentation including README with usage examples
This commit is contained in:
173
.gitignore
vendored
Normal file
173
.gitignore
vendored
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
# ---> Python
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
venv/*
|
||||||
|
Documents.md
|
||||||
|
test.py
|
11
LICENSE
Normal file
11
LICENSE
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||||
|
Version 2, December 2004
|
||||||
|
|
||||||
|
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim or modified copies of this license document, and changing it is allowed as long as the name is changed.
|
||||||
|
|
||||||
|
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
0. You just DO WHAT THE FUCK YOU WANT TO.
|
284
README.md
Normal file
284
README.md
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
# API Client Library
|
||||||
|
|
||||||
|
A comprehensive Python client library for a music streaming API. This library provides both programmatic access and command-line interface (CLI) for interacting with songbox's music platform.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🔐 **Authentication**: Login with mobile number and OTP verification
|
||||||
|
- 👤 **User Management**: Get user profile, following lists, and user operations
|
||||||
|
- 🎵 **Music Operations**: Search songs, get albums, artists, and trending music
|
||||||
|
- 🖥️ **CLI Support**: Full command-line interface for all operations
|
||||||
|
- 🔧 **Modular Design**: Separate clients for different API categories
|
||||||
|
- 📦 **Easy Installation**: Simple pip install with all dependencies
|
||||||
|
- 🛡️ **Error Handling**: Comprehensive exception handling with meaningful errors
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install songbox
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install from source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/songbox.git
|
||||||
|
cd songbox
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Python Library Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from songbox import songboxClient
|
||||||
|
|
||||||
|
# Create client
|
||||||
|
client = songboxClient()
|
||||||
|
|
||||||
|
# Authenticate
|
||||||
|
verify_id = client.auth.login("960", "7777777")
|
||||||
|
opt_code = input("Enter OTP: ")
|
||||||
|
token = client.auth.verify_otp(otp_code, verify_id)
|
||||||
|
|
||||||
|
# Get user info
|
||||||
|
user_info = client.user.get_me()
|
||||||
|
print(f"Welcome, {user_info['username']}!")
|
||||||
|
|
||||||
|
# Search for music
|
||||||
|
results = client.music.search("huttaa")
|
||||||
|
for category in results:
|
||||||
|
print(f"Category: {category['title']}")
|
||||||
|
for item in category['items']:
|
||||||
|
print(f" - {item['heading']} by {item['sub_heading']}")
|
||||||
|
|
||||||
|
# Get album details
|
||||||
|
album = client.music.get_album("808")
|
||||||
|
print(f"Album: {album['name']} by {album['artist_name']}")
|
||||||
|
print(f"Songs: {len(album['songs'])}")
|
||||||
|
|
||||||
|
# Close client when done
|
||||||
|
client.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
### CLI Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login
|
||||||
|
songbox auth login --country-code 960 --mobile 7777777
|
||||||
|
songbox auth verify --otp 123456 --verify-id <verify_id>
|
||||||
|
|
||||||
|
# Set token for subsequent commands
|
||||||
|
export songbox_TOKEN="your_jwt_token_here"
|
||||||
|
|
||||||
|
# Get user info
|
||||||
|
songbox user me
|
||||||
|
|
||||||
|
# Search for music
|
||||||
|
songbox music search "huttaa"
|
||||||
|
|
||||||
|
# Get album information
|
||||||
|
songbox music album 808
|
||||||
|
|
||||||
|
# Get trending music
|
||||||
|
songbox music trending --limit 10
|
||||||
|
|
||||||
|
# Different output formats
|
||||||
|
songbox music search "huttaa" --format json
|
||||||
|
songbox user me --format table
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Authentication (`client.auth`)
|
||||||
|
|
||||||
|
- `login(country_code, mobile_number)` - Initiate login with mobile number
|
||||||
|
- `verify_otp(otp, verify_id)` - Verify OTP and get authentication token
|
||||||
|
- `refresh_token()` - Refresh the current authentication token
|
||||||
|
- `logout()` - Clear authentication token
|
||||||
|
- `get_token_info()` - Get information about current token
|
||||||
|
|
||||||
|
### User Management (`client.user`)
|
||||||
|
|
||||||
|
- `get_me()` - Get current user's profile information
|
||||||
|
- `get_following()` - Get list of followed users/artists
|
||||||
|
- `get_user_profile(user_id)` - Get profile of specific user
|
||||||
|
- `follow_user(user_id)` - Follow a user or artist
|
||||||
|
- `unfollow_user(user_id)` - Unfollow a user or artist
|
||||||
|
- `update_profile(**kwargs)` - Update current user's profile
|
||||||
|
|
||||||
|
### Music Operations (`client.music`)
|
||||||
|
|
||||||
|
- `search(query)` - Search for songs, artists, and albums
|
||||||
|
- `get_album(album_id)` - Get detailed album information
|
||||||
|
- `get_song(song_id)` - Get detailed song information
|
||||||
|
- `get_artist(artist_id)` - Get detailed artist information
|
||||||
|
- `get_recommendations(limit=None)` - Get personalized recommendations
|
||||||
|
- `get_playlists()` - Get user's playlists
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
- `songbox_TOKEN` - Authentication token for CLI usage
|
||||||
|
|
||||||
|
### Client Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
client = songboxClient(
|
||||||
|
timeout=30.0, # Request timeout in seconds
|
||||||
|
headers={"Custom-Header": "value"} # Additional headers
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The library provides specific exceptions for different error types:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from songbox.exceptions import (
|
||||||
|
songboxError,
|
||||||
|
AuthenticationError,
|
||||||
|
APIError,
|
||||||
|
NotFoundError,
|
||||||
|
ValidationError
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_info = client.user.get_me()
|
||||||
|
except AuthenticationError:
|
||||||
|
print("Please login first")
|
||||||
|
except NotFoundError:
|
||||||
|
print("User not found")
|
||||||
|
except APIError as e:
|
||||||
|
print(f"API Error: {e.message} (Status: {e.status_code})")
|
||||||
|
except songboxError as e:
|
||||||
|
print(f"General error: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
### Authentication Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
songbox auth login --country-code <code> --mobile <number>
|
||||||
|
songbox auth verify --otp <otp> --verify-id <verify_id>
|
||||||
|
songbox auth refresh
|
||||||
|
songbox auth logout
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
songbox user me
|
||||||
|
songbox user following
|
||||||
|
```
|
||||||
|
|
||||||
|
### Music Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
songbox music search <query>
|
||||||
|
songbox music album <album_id>
|
||||||
|
songbox music song <song_id>
|
||||||
|
songbox music artist <artist_id>
|
||||||
|
songbox music trending [--limit <number>]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output Formats
|
||||||
|
|
||||||
|
- `--format simple` (default) - Human-readable format
|
||||||
|
- `--format json` - JSON output
|
||||||
|
- `--format table` - Tabular format
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Complete Authentication Flow
|
||||||
|
|
||||||
|
```python
|
||||||
|
from songbox import songboxClient
|
||||||
|
from songbox.exceptions import AuthenticationError
|
||||||
|
|
||||||
|
client = songboxClient()
|
||||||
|
|
||||||
|
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:
|
||||||
|
client.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Music Discovery
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Search and explore music
|
||||||
|
results = client.music.search("maldivian music")
|
||||||
|
|
||||||
|
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:
|
||||||
|
song_details = client.music.get_song(item['destination_id'])
|
||||||
|
print(f" Duration: {song_details.get('duration', 'Unknown')}s")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Setting up for Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/songbox.git
|
||||||
|
cd songbox
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Formatting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
black songbox/
|
||||||
|
flake8 songbox/
|
||||||
|
mypy songbox/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Note**: This is an unofficial client library. songbox is a trademark of its respective owners.
|
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
httpx==0.28.1
|
58
setup.py
Normal file
58
setup.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"""Setup script for songbox API Client Library"""
|
||||||
|
|
||||||
|
from setuptools import setup, find_packages
|
||||||
|
|
||||||
|
with open("README.md", "r", encoding="utf-8") as fh:
|
||||||
|
long_description = fh.read()
|
||||||
|
|
||||||
|
with open("requirements.txt", "r", encoding="utf-8") as fh:
|
||||||
|
requirements = [line.strip() for line in fh if line.strip() and not line.startswith("#")]
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name="songbox",
|
||||||
|
version="1.0.0",
|
||||||
|
author="CustomIcon",
|
||||||
|
author_email="custom.icon@vk.com",
|
||||||
|
description="A Python client library for a music streaming API",
|
||||||
|
long_description=long_description,
|
||||||
|
long_description_content_type="text/markdown",
|
||||||
|
url="https://git.cubable.date/CustomIcon/songbox",
|
||||||
|
packages=find_packages(),
|
||||||
|
classifiers=[
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.7",
|
||||||
|
"Programming Language :: Python :: 3.8",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
|
"Topic :: Multimedia :: Sound/Audio",
|
||||||
|
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
||||||
|
],
|
||||||
|
python_requires=">=3.7",
|
||||||
|
install_requires=requirements,
|
||||||
|
extras_require={
|
||||||
|
"dev": [
|
||||||
|
"pytest>=6.0",
|
||||||
|
"pytest-asyncio",
|
||||||
|
"black",
|
||||||
|
"flake8",
|
||||||
|
"mypy",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
entry_points={
|
||||||
|
"console_scripts": [
|
||||||
|
"songbox=songbox.__main__:main",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
keywords="songbox music api client streaming maldives",
|
||||||
|
project_urls={
|
||||||
|
"Bug Reports": "https://github.com/yourusername/songbox/issues",
|
||||||
|
"Source": "https://github.com/yourusername/songbox",
|
||||||
|
"Documentation": "https://github.com/yourusername/songbox#readme",
|
||||||
|
},
|
||||||
|
)
|
32
songbox/__init__.py
Normal file
32
songbox/__init__.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"""songbox API Client Library
|
||||||
|
|
||||||
|
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 .exceptions import (
|
||||||
|
songboxError,
|
||||||
|
AuthenticationError,
|
||||||
|
APIError,
|
||||||
|
RateLimitError,
|
||||||
|
NotFoundError
|
||||||
|
)
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
__author__ = "songbox Library"
|
||||||
|
__email__ = "custom.icon@vk.com"
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"songboxClient",
|
||||||
|
"AuthClient",
|
||||||
|
"UserClient",
|
||||||
|
"MusicClient",
|
||||||
|
"songboxError",
|
||||||
|
"AuthenticationError",
|
||||||
|
"APIError",
|
||||||
|
"RateLimitError",
|
||||||
|
"NotFoundError",
|
||||||
|
]
|
290
songbox/__main__.py
Normal file
290
songbox/__main__.py
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
"""Command-line interface for songbox API Client"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
from .client import songboxClient
|
||||||
|
from .exceptions import songboxError, AuthenticationError
|
||||||
|
|
||||||
|
|
||||||
|
def create_parser() -> argparse.ArgumentParser:
|
||||||
|
"""Create the argument parser for the CLI."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="songbox",
|
||||||
|
description="songbox API Client - Access songbox music streaming API from command line",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
# Login and get user info
|
||||||
|
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
|
||||||
|
|
||||||
|
# Get user's following list
|
||||||
|
songbox user following
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--token",
|
||||||
|
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)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--format",
|
||||||
|
choices=["json", "table", "simple"],
|
||||||
|
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.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.add_argument("--otp", required=True, help="OTP code")
|
||||||
|
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")
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def format_output(data, format_type: str) -> str:
|
||||||
|
"""Format output based on the specified format."""
|
||||||
|
if format_type == "json":
|
||||||
|
return json.dumps(data, indent=2, ensure_ascii=False)
|
||||||
|
elif format_type == "table":
|
||||||
|
# Simple table format for lists
|
||||||
|
if isinstance(data, list) and data:
|
||||||
|
if isinstance(data[0], dict):
|
||||||
|
# Print headers
|
||||||
|
headers = list(data[0].keys())
|
||||||
|
result = "\t".join(headers) + "\n"
|
||||||
|
# Print rows
|
||||||
|
for item in data:
|
||||||
|
row = [str(item.get(h, "")) for h in headers]
|
||||||
|
result += "\t".join(row) + "\n"
|
||||||
|
return result
|
||||||
|
return str(data)
|
||||||
|
else:
|
||||||
|
if isinstance(data, dict):
|
||||||
|
result = ""
|
||||||
|
for key, value in data.items():
|
||||||
|
result += f"{key}: {value}\n"
|
||||||
|
return result
|
||||||
|
elif isinstance(data, list):
|
||||||
|
result = ""
|
||||||
|
for i, item in enumerate(data):
|
||||||
|
if isinstance(item, dict):
|
||||||
|
result += f"Item {i + 1}:\n"
|
||||||
|
for key, value in item.items():
|
||||||
|
result += f" {key}: {value}\n"
|
||||||
|
result += "\n"
|
||||||
|
else:
|
||||||
|
result += f"{i + 1}. {item}\n"
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
return str(data)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_auth_commands(client: songboxClient, args) -> None:
|
||||||
|
"""Handle authentication commands."""
|
||||||
|
if args.auth_command == "login":
|
||||||
|
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}")
|
||||||
|
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")
|
||||||
|
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()
|
||||||
|
print(f"Token refreshed successfully!")
|
||||||
|
print(f"New token: {new_token}")
|
||||||
|
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!")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_user_commands(client: songboxClient, args) -> None:
|
||||||
|
"""Handle user commands."""
|
||||||
|
if args.user_command == "me":
|
||||||
|
try:
|
||||||
|
user_info = client.user.get_me()
|
||||||
|
print(format_output(user_info, args.format))
|
||||||
|
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()
|
||||||
|
if not following:
|
||||||
|
print("Not following anyone yet.")
|
||||||
|
else:
|
||||||
|
print(format_output(following, args.format))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to get following list: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_music_commands(client: songboxClient, args) -> None:
|
||||||
|
"""Handle music commands."""
|
||||||
|
if args.music_command == "search":
|
||||||
|
try:
|
||||||
|
results = client.music.search(args.query)
|
||||||
|
if not results:
|
||||||
|
print(f"No results found for '{args.query}'")
|
||||||
|
else:
|
||||||
|
print(format_output(results, args.format))
|
||||||
|
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)
|
||||||
|
print(format_output(album, args.format))
|
||||||
|
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)
|
||||||
|
print(format_output(song, args.format))
|
||||||
|
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)
|
||||||
|
print(format_output(artist, args.format))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to get artist: {e}", file=sys.stderr)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
sys.exit(1)
|
||||||
|
handle_music_commands(client, args)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nOperation cancelled.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
if 'client' in locals():
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
178
songbox/auth.py
Normal file
178
songbox/auth.py
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
"""Authentication client for songbox API"""
|
||||||
|
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from .exceptions import AuthenticationError, ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class AuthClient:
|
||||||
|
"""Client for handling authentication with the songbox API.
|
||||||
|
|
||||||
|
This client handles login, OTP verification, and token refresh operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client):
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def login(self, country_code: str, mobile_number: str) -> str:
|
||||||
|
"""Initiate login process by sending OTP to mobile number.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
country_code: Country code (e.g., "960" for Maldives)
|
||||||
|
mobile_number: Mobile number without country code
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
verify_id: ID needed for OTP verification
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If input parameters are invalid
|
||||||
|
AuthenticationError: If login initiation fails
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> auth = AuthClient(client)
|
||||||
|
>>> verify_id = auth.login("960", "7777777")
|
||||||
|
"""
|
||||||
|
if not country_code or not mobile_number:
|
||||||
|
raise ValidationError("Country code and mobile number are required")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"country_code": country_code,
|
||||||
|
"mobile_number": mobile_number
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method="POST",
|
||||||
|
endpoint="/v2/auth/init",
|
||||||
|
data=data,
|
||||||
|
require_auth=False
|
||||||
|
)
|
||||||
|
|
||||||
|
verify_id = response.get("verify_id")
|
||||||
|
if not verify_id:
|
||||||
|
raise AuthenticationError("Failed to get verification ID")
|
||||||
|
|
||||||
|
return verify_id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, (AuthenticationError, ValidationError)):
|
||||||
|
raise
|
||||||
|
raise AuthenticationError(f"Login failed: {str(e)}")
|
||||||
|
|
||||||
|
def verify_otp(self, otp: str, verify_id: str) -> str:
|
||||||
|
"""Verify OTP and get authentication token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
otp: One-time password received via SMS
|
||||||
|
verify_id: Verification ID from login step
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
token: JWT authentication token
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If input parameters are invalid
|
||||||
|
AuthenticationError: If OTP verification fails
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> token = auth.verify_otp("123456", verify_id)
|
||||||
|
>>> client.set_token(token)
|
||||||
|
"""
|
||||||
|
if not otp or not verify_id:
|
||||||
|
raise ValidationError("OTP and verify_id are required")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"otp": otp,
|
||||||
|
"verify_id": verify_id
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method="POST",
|
||||||
|
endpoint="/v2/sign_in?auth_method=otp",
|
||||||
|
data=data,
|
||||||
|
require_auth=False
|
||||||
|
)
|
||||||
|
|
||||||
|
token = response.get("token")
|
||||||
|
if not token:
|
||||||
|
raise AuthenticationError("Failed to get authentication token")
|
||||||
|
|
||||||
|
# Automatically set the token in the client
|
||||||
|
self._client.set_token(token)
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, (AuthenticationError, ValidationError)):
|
||||||
|
raise
|
||||||
|
raise AuthenticationError(f"OTP verification failed: {str(e)}")
|
||||||
|
|
||||||
|
def refresh_token(self) -> str:
|
||||||
|
"""Refresh the current authentication token.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
token: New JWT authentication token
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationError: If token refresh fails
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> new_token = auth.refresh_token()
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method="GET",
|
||||||
|
endpoint="/v1/refresh_token",
|
||||||
|
require_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
|
token = response.get("token")
|
||||||
|
if not token:
|
||||||
|
raise AuthenticationError("Failed to refresh token")
|
||||||
|
|
||||||
|
# Update the token in the client
|
||||||
|
self._client.set_token(token)
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, AuthenticationError):
|
||||||
|
raise
|
||||||
|
raise AuthenticationError(f"Token refresh failed: {str(e)}")
|
||||||
|
|
||||||
|
def logout(self) -> None:
|
||||||
|
"""Logout by clearing the authentication token.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> auth.logout()
|
||||||
|
"""
|
||||||
|
self._client.clear_token()
|
||||||
|
|
||||||
|
def get_token_info(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get information about the current token.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Token information including expiry date, or None if not authenticated
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> info = auth.get_token_info()
|
||||||
|
>>> if info:
|
||||||
|
... print(f"Token expires: {info.get('expire')}")
|
||||||
|
"""
|
||||||
|
if not self._client.is_authenticated:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method="GET",
|
||||||
|
endpoint="/v1/refresh_token",
|
||||||
|
require_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"token": response.get("token"),
|
||||||
|
"expire": response.get("expire"),
|
||||||
|
"code": response.get("code")
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return None
|
167
songbox/client.py
Normal file
167
songbox/client.py
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
"""Main songbox API Client"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
>>> verify_id = client.auth.login("960", "7777777")
|
||||||
|
>>> token = client.auth.verify_otp("123456", verify_id)
|
||||||
|
>>> client.set_token(token)
|
||||||
|
>>> # Get user info
|
||||||
|
>>> 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
|
||||||
|
):
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
if headers:
|
||||||
|
default_headers.update(headers)
|
||||||
|
|
||||||
|
# Create HTTP client
|
||||||
|
self._http_client = httpx.Client(
|
||||||
|
base_url=self.base_url,
|
||||||
|
timeout=timeout,
|
||||||
|
headers=default_headers,
|
||||||
|
follow_redirects=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize sub-clients
|
||||||
|
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,
|
||||||
|
endpoint: str,
|
||||||
|
params: Optional[Dict[str, Any]] = None,
|
||||||
|
data: Optional[Dict[str, Any]] = None,
|
||||||
|
json: Optional[Dict[str, Any]] = None,
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
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)
|
||||||
|
params: Query parameters
|
||||||
|
data: Form data for POST requests
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Prepare request
|
||||||
|
url = endpoint if endpoint.startswith('http') else f"/api{endpoint}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._http_client.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
params=params,
|
||||||
|
data=data,
|
||||||
|
json=json,
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for HTTP errors
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Parse JSON response
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except ValueError:
|
||||||
|
# Handle empty responses
|
||||||
|
return {}
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
# Try to get error message from response
|
||||||
|
try:
|
||||||
|
error_data = e.response.json()
|
||||||
|
message = error_data.get('message', f'HTTP {e.response.status_code}')
|
||||||
|
except ValueError:
|
||||||
|
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()
|
54
songbox/exceptions.py
Normal file
54
songbox/exceptions.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"""songbox API Client Exceptions"""
|
||||||
|
|
||||||
|
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
|
||||||
|
):
|
||||||
|
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}"
|
||||||
|
return f"API Error: {self.message}"
|
||||||
|
|
||||||
|
|
||||||
|
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
|
247
songbox/music.py
Normal file
247
songbox/music.py
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
"""Music client for songbox API"""
|
||||||
|
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from .exceptions import AuthenticationError, NotFoundError, ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class MusicClient:
|
||||||
|
"""Client for handling music-related operations with the songbox API.
|
||||||
|
|
||||||
|
This client handles search, albums, songs, and other music operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client):
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def search(self, query: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Search for songs, artists, and albums.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of search results categorized by type (Songs, Artists, Albums)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If query is empty
|
||||||
|
AuthenticationError: If not authenticated
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> results = client.music.search("huttaa")
|
||||||
|
>>> for category in results:
|
||||||
|
... print(f"Category: {category['title']}")
|
||||||
|
... for item in category['items']:
|
||||||
|
... print(f" - {item['heading']}")
|
||||||
|
"""
|
||||||
|
if not query or not query.strip():
|
||||||
|
raise ValidationError("Search query cannot be empty")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method="GET",
|
||||||
|
endpoint=f"/v1/search/{query.strip()}",
|
||||||
|
require_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure we return a list
|
||||||
|
if isinstance(response, list):
|
||||||
|
return response
|
||||||
|
elif isinstance(response, dict):
|
||||||
|
# If response is a dict, try to extract results
|
||||||
|
return response.get('results', response.get('data', []))
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if "401" in str(e) or "unauthorized" in str(e).lower():
|
||||||
|
raise AuthenticationError("Authentication required or token expired")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_album(self, album_id: str) -> Dict[str, Any]:
|
||||||
|
"""Get detailed information about an album.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
album_id: ID of the album
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Album information including songs, artist, artwork, etc.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If album_id is empty
|
||||||
|
AuthenticationError: If not authenticated
|
||||||
|
NotFoundError: If album not found
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> album = client.music.get_album("808")
|
||||||
|
>>> print(f"Album: {album['name']} by {album['artist_name']}")
|
||||||
|
>>> print(f"Songs: {len(album['songs'])}")
|
||||||
|
"""
|
||||||
|
if not album_id or not str(album_id).strip():
|
||||||
|
raise ValidationError("Album ID cannot be empty")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method="GET",
|
||||||
|
endpoint=f"/v1/albums/{album_id}",
|
||||||
|
require_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if "401" in str(e) or "unauthorized" in str(e).lower():
|
||||||
|
raise AuthenticationError("Authentication required or token expired")
|
||||||
|
elif "404" in str(e) or "not found" in str(e).lower():
|
||||||
|
raise NotFoundError(f"Album with ID {album_id} not found")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_song(self, song_id: str) -> Dict[str, Any]:
|
||||||
|
"""Get detailed information about a song.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
song_id: ID of the song
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Song information including artist, album, duration, URLs, etc.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If song_id is empty
|
||||||
|
AuthenticationError: If not authenticated
|
||||||
|
NotFoundError: If song not found
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> song = client.music.get_song("6200")
|
||||||
|
>>> print(f"Song: {song['name']} by {song['artist_name']}")
|
||||||
|
>>> print(f"Duration: {song['duration']} seconds")
|
||||||
|
"""
|
||||||
|
if not song_id or not str(song_id).strip():
|
||||||
|
raise ValidationError("Song ID cannot be empty")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method="GET",
|
||||||
|
endpoint=f"/v1/songs/{song_id}",
|
||||||
|
require_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if "401" in str(e) or "unauthorized" in str(e).lower():
|
||||||
|
raise AuthenticationError("Authentication required or token expired")
|
||||||
|
elif "404" in str(e) or "not found" in str(e).lower():
|
||||||
|
raise NotFoundError(f"Song with ID {song_id} not found")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_artist(self, artist_id: str) -> Dict[str, Any]:
|
||||||
|
"""Get detailed information about an artist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artist_id: ID of the artist
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Artist information including albums, songs, etc.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If artist_id is empty
|
||||||
|
AuthenticationError: If not authenticated
|
||||||
|
NotFoundError: If artist not found
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> artist = client.music.get_artist("124")
|
||||||
|
>>> print(f"Artist: {artist['name']}")
|
||||||
|
"""
|
||||||
|
if not artist_id or not str(artist_id).strip():
|
||||||
|
raise ValidationError("Artist ID cannot be empty")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method="GET",
|
||||||
|
endpoint=f"/v1/artists/{artist_id}",
|
||||||
|
require_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if "401" in str(e) or "unauthorized" in str(e).lower():
|
||||||
|
raise AuthenticationError("Authentication required or token expired")
|
||||||
|
elif "404" in str(e) or "not found" in str(e).lower():
|
||||||
|
raise NotFoundError(f"Artist with ID {artist_id} not found")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_recommendations(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""Get personalized music recommendations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of items to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of recommended music items
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationError: If not authenticated
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> recommendations = client.music.get_recommendations(limit=5)
|
||||||
|
>>> for item in recommendations:
|
||||||
|
... print(f"Recommended: {item.get('name', 'Unknown')}")
|
||||||
|
"""
|
||||||
|
params = {}
|
||||||
|
if limit is not None:
|
||||||
|
params['limit'] = limit
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method="GET",
|
||||||
|
endpoint="/v1/recommendations",
|
||||||
|
params=params,
|
||||||
|
require_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure we return a list
|
||||||
|
if isinstance(response, list):
|
||||||
|
return response
|
||||||
|
elif isinstance(response, dict):
|
||||||
|
return response.get('data', response.get('items', []))
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if "401" in str(e) or "unauthorized" in str(e).lower():
|
||||||
|
raise AuthenticationError("Authentication required or token expired")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_playlists(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get user's playlists.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of user's playlists
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationError: If not authenticated
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> playlists = client.music.get_playlists()
|
||||||
|
>>> for playlist in playlists:
|
||||||
|
... print(f"Playlist: {playlist.get('name', 'Unknown')}")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method="GET",
|
||||||
|
endpoint="/v1/playlists",
|
||||||
|
require_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure we return a list
|
||||||
|
if isinstance(response, list):
|
||||||
|
return response
|
||||||
|
elif isinstance(response, dict):
|
||||||
|
return response.get('data', response.get('playlists', []))
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if "401" in str(e) or "unauthorized" in str(e).lower():
|
||||||
|
raise AuthenticationError("Authentication required or token expired")
|
||||||
|
raise
|
216
songbox/user.py
Normal file
216
songbox/user.py
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
"""User client for songbox API"""
|
||||||
|
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from .exceptions import AuthenticationError, NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
class UserClient:
|
||||||
|
"""Client for handling user-related operations with the songbox API.
|
||||||
|
|
||||||
|
This client handles user profile information, following, and other user operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client):
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def get_me(self) -> Dict[str, Any]:
|
||||||
|
"""Get current user's profile information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User profile data including id, username, email, subscription info
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationError: If not authenticated
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> user_info = client.user.get_me()
|
||||||
|
>>> print(f"Username: {user_info['username']}")
|
||||||
|
>>> print(f"Subscription: {user_info['subscription']}")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method="GET",
|
||||||
|
endpoint="/v1/me",
|
||||||
|
require_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if "401" in str(e) or "unauthorized" in str(e).lower():
|
||||||
|
raise AuthenticationError("Authentication required or token expired")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_following(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get list of users/artists that the current user is following.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of followed users/artists
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationError: If not authenticated
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> following = client.user.get_following()
|
||||||
|
>>> for user in following:
|
||||||
|
... print(f"Following: {user.get('name', 'Unknown')}")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method="GET",
|
||||||
|
endpoint="/v1/me/following",
|
||||||
|
require_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle empty response (API returns {} when no following)
|
||||||
|
if isinstance(response, dict) and not response:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# If response is a list, return it
|
||||||
|
if isinstance(response, list):
|
||||||
|
return response
|
||||||
|
|
||||||
|
# If response has a 'data' or 'following' key, return that
|
||||||
|
if isinstance(response, dict):
|
||||||
|
return response.get('data', response.get('following', []))
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if "401" in str(e) or "unauthorized" in str(e).lower():
|
||||||
|
raise AuthenticationError("Authentication required or token expired")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_user_profile(self, user_id: str) -> Dict[str, Any]:
|
||||||
|
"""Get profile information for a specific user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the user to get profile for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User profile data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationError: If not authenticated
|
||||||
|
NotFoundError: If user not found
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> profile = client.user.get_user_profile("12345")
|
||||||
|
>>> print(f"User: {profile['username']}")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method="GET",
|
||||||
|
endpoint=f"/v1/users/{user_id}",
|
||||||
|
require_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if "401" in str(e) or "unauthorized" in str(e).lower():
|
||||||
|
raise AuthenticationError("Authentication required or token expired")
|
||||||
|
elif "404" in str(e) or "not found" in str(e).lower():
|
||||||
|
raise NotFoundError(f"User with ID {user_id} not found")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def follow_user(self, user_id: str) -> bool:
|
||||||
|
"""Follow a user or artist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the user/artist to follow
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationError: If not authenticated
|
||||||
|
NotFoundError: If user not found
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> success = client.user.follow_user("12345")
|
||||||
|
>>> if success:
|
||||||
|
... print("Successfully followed user")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method="POST",
|
||||||
|
endpoint=f"/v1/users/{user_id}/follow",
|
||||||
|
require_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if the response indicates success
|
||||||
|
return response.get('success', True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if "401" in str(e) or "unauthorized" in str(e).lower():
|
||||||
|
raise AuthenticationError("Authentication required or token expired")
|
||||||
|
elif "404" in str(e) or "not found" in str(e).lower():
|
||||||
|
raise NotFoundError(f"User with ID {user_id} not found")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def unfollow_user(self, user_id: str) -> bool:
|
||||||
|
"""Unfollow a user or artist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the user/artist to unfollow
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationError: If not authenticated
|
||||||
|
NotFoundError: If user not found
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> success = client.user.unfollow_user("12345")
|
||||||
|
>>> if success:
|
||||||
|
... print("Successfully unfollowed user")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method="DELETE",
|
||||||
|
endpoint=f"/v1/users/{user_id}/follow",
|
||||||
|
require_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if the response indicates success
|
||||||
|
return response.get('success', True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if "401" in str(e) or "unauthorized" in str(e).lower():
|
||||||
|
raise AuthenticationError("Authentication required or token expired")
|
||||||
|
elif "404" in str(e) or "not found" in str(e).lower():
|
||||||
|
raise NotFoundError(f"User with ID {user_id} not found")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def update_profile(self, **kwargs) -> Dict[str, Any]:
|
||||||
|
"""Update current user's profile information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
**kwargs: Profile fields to update (e.g., username, email)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated profile data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationError: If not authenticated
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> updated = client.user.update_profile(username="newname")
|
||||||
|
>>> print(f"Updated username: {updated['username']}")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method="PUT",
|
||||||
|
endpoint="/v1/me",
|
||||||
|
json=kwargs,
|
||||||
|
require_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if "401" in str(e) or "unauthorized" in str(e).lower():
|
||||||
|
raise AuthenticationError("Authentication required or token expired")
|
||||||
|
raise
|
Reference in New Issue
Block a user