feat(music): add song download functionality and rename client class
- Implement download_song method in MusicClient with progress tracking - Rename songboxClient to SongboxClient for consistency - Update documentation and examples to reflect class name change - Add download command to CLI interface
This commit is contained in:
2
LICENSE
2
LICENSE
@ -1,7 +1,7 @@
|
|||||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||||
Version 2, December 2004
|
Version 2, December 2004
|
||||||
|
|
||||||
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
|
Copyright (C) 2025 CustomIcon <custom.icon@vk.com>
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
10
README.md
10
README.md
@ -31,10 +31,10 @@ pip install -e .
|
|||||||
### Python Library Usage
|
### Python Library Usage
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from songbox import songboxClient
|
from songbox import SongboxClient
|
||||||
|
|
||||||
# Create client
|
# Create client
|
||||||
client = songboxClient()
|
client = SongboxClient()
|
||||||
|
|
||||||
# Authenticate
|
# Authenticate
|
||||||
verify_id = client.auth.login("960", "7777777")
|
verify_id = client.auth.login("960", "7777777")
|
||||||
@ -125,7 +125,7 @@ songbox user me --format table
|
|||||||
### Client Configuration
|
### Client Configuration
|
||||||
|
|
||||||
```python
|
```python
|
||||||
client = songboxClient(
|
client = SongboxClient(
|
||||||
timeout=30.0, # Request timeout in seconds
|
timeout=30.0, # Request timeout in seconds
|
||||||
headers={"Custom-Header": "value"} # Additional headers
|
headers={"Custom-Header": "value"} # Additional headers
|
||||||
)
|
)
|
||||||
@ -195,10 +195,10 @@ songbox music trending [--limit <number>]
|
|||||||
### Complete Authentication Flow
|
### Complete Authentication Flow
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from songbox import songboxClient
|
from songbox import SongboxClient
|
||||||
from songbox.exceptions import AuthenticationError
|
from songbox.exceptions import AuthenticationError
|
||||||
|
|
||||||
client = songboxClient()
|
client = SongboxClient()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Step 1: Initiate login
|
# Step 1: Initiate login
|
||||||
|
14
example.py
Normal file
14
example.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from songbox import SongboxClient
|
||||||
|
|
||||||
|
client = SongboxClient()
|
||||||
|
verify_id = client.auth.login("960", "777777")
|
||||||
|
otp_code = input("Enter OTP: ")
|
||||||
|
token = client.auth.verify_otp(otp_code, verify_id)
|
||||||
|
print(f"Your Token is: {token}")
|
||||||
|
client.set_token(token)
|
||||||
|
|
||||||
|
song = client.music.get_song(6200)
|
||||||
|
file_path = client.music.download_song(song['url_original'])
|
||||||
|
print(f"\nDownload completed successfully!")
|
||||||
|
print(f"File saved to: {file_path}")
|
||||||
|
client.close()
|
6
setup.py
6
setup.py
@ -51,8 +51,8 @@ setup(
|
|||||||
},
|
},
|
||||||
keywords="songbox music api client streaming maldives",
|
keywords="songbox music api client streaming maldives",
|
||||||
project_urls={
|
project_urls={
|
||||||
"Bug Reports": "https://github.com/yourusername/songbox/issues",
|
"Bug Reports": "https://git.cubable.date/CustomIcon/songbox/issues",
|
||||||
"Source": "https://github.com/yourusername/songbox",
|
"Source": "https://git.cubable.date/CustomIcon/songbox",
|
||||||
"Documentation": "https://github.com/yourusername/songbox#readme",
|
"Documentation": "https://git.cubable.date/CustomIcon/songbox/src/branch/master/README.md",
|
||||||
},
|
},
|
||||||
)
|
)
|
@ -3,7 +3,7 @@
|
|||||||
A Python client library for the songbox music streaming API.
|
A Python client library for the songbox music streaming API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .client import songboxClient
|
from .client import SongboxClient
|
||||||
from .auth import AuthClient
|
from .auth import AuthClient
|
||||||
from .user import UserClient
|
from .user import UserClient
|
||||||
from .music import MusicClient
|
from .music import MusicClient
|
||||||
@ -20,7 +20,7 @@ __author__ = "songbox Library"
|
|||||||
__email__ = "custom.icon@vk.com"
|
__email__ = "custom.icon@vk.com"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"songboxClient",
|
"SongboxClient",
|
||||||
"AuthClient",
|
"AuthClient",
|
||||||
"UserClient",
|
"UserClient",
|
||||||
"MusicClient",
|
"MusicClient",
|
||||||
|
@ -4,7 +4,7 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from .client import songboxClient
|
from .client import SongboxClient
|
||||||
from .exceptions import songboxError, AuthenticationError
|
from .exceptions import songboxError, AuthenticationError
|
||||||
|
|
||||||
|
|
||||||
@ -27,6 +27,10 @@ Examples:
|
|||||||
# Get album information
|
# Get album information
|
||||||
songbox music album 808
|
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
|
# Get user's following list
|
||||||
songbox user following
|
songbox user following
|
||||||
"""
|
"""
|
||||||
@ -103,6 +107,11 @@ Examples:
|
|||||||
trending_parser = music_subparsers.add_parser("trending", help="Get trending music")
|
trending_parser = music_subparsers.add_parser("trending", help="Get trending music")
|
||||||
trending_parser.add_argument("--limit", type=int, help="Limit number of results")
|
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)")
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@ -144,7 +153,7 @@ def format_output(data, format_type: str) -> str:
|
|||||||
return str(data)
|
return str(data)
|
||||||
|
|
||||||
|
|
||||||
def handle_auth_commands(client: songboxClient, args) -> None:
|
def handle_auth_commands(client: SongboxClient, args) -> None:
|
||||||
"""Handle authentication commands."""
|
"""Handle authentication commands."""
|
||||||
if args.auth_command == "login":
|
if args.auth_command == "login":
|
||||||
try:
|
try:
|
||||||
@ -179,7 +188,7 @@ def handle_auth_commands(client: songboxClient, args) -> None:
|
|||||||
print("Logged out successfully!")
|
print("Logged out successfully!")
|
||||||
|
|
||||||
|
|
||||||
def handle_user_commands(client: songboxClient, args) -> None:
|
def handle_user_commands(client: SongboxClient, args) -> None:
|
||||||
"""Handle user commands."""
|
"""Handle user commands."""
|
||||||
if args.user_command == "me":
|
if args.user_command == "me":
|
||||||
try:
|
try:
|
||||||
@ -201,7 +210,7 @@ def handle_user_commands(client: songboxClient, args) -> None:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def handle_music_commands(client: songboxClient, args) -> None:
|
def handle_music_commands(client: SongboxClient, args) -> None:
|
||||||
"""Handle music commands."""
|
"""Handle music commands."""
|
||||||
if args.music_command == "search":
|
if args.music_command == "search":
|
||||||
try:
|
try:
|
||||||
@ -237,6 +246,15 @@ def handle_music_commands(client: songboxClient, args) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to get artist: {e}", file=sys.stderr)
|
print(f"Failed to get artist: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.music_command == "download":
|
||||||
|
try:
|
||||||
|
file_path = client.music.download_song(args.song_url, args.path)
|
||||||
|
print(f"\nDownload completed successfully!")
|
||||||
|
print(f"File saved to: {file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Download failed: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -255,7 +273,7 @@ def main():
|
|||||||
|
|
||||||
# Create client
|
# Create client
|
||||||
try:
|
try:
|
||||||
client = songboxClient(base_url=args.base_url)
|
client = SongboxClient(base_url=args.base_url)
|
||||||
|
|
||||||
# Set token if provided
|
# Set token if provided
|
||||||
if token:
|
if token:
|
||||||
|
@ -8,7 +8,7 @@ from .music import MusicClient
|
|||||||
from .exceptions import songboxError, APIError
|
from .exceptions import songboxError, APIError
|
||||||
|
|
||||||
|
|
||||||
class songboxClient:
|
class SongboxClient:
|
||||||
"""Main client for interacting with the songbox API.
|
"""Main client for interacting with the songbox API.
|
||||||
|
|
||||||
This client provides access to all songbox API endpoints through
|
This client provides access to all songbox API endpoints through
|
||||||
@ -20,7 +20,7 @@ class songboxClient:
|
|||||||
headers: Additional headers to include with requests
|
headers: Additional headers to include with requests
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> client = songboxClient()
|
>>> client = SongboxClient()
|
||||||
>>> # Authenticate
|
>>> # Authenticate
|
||||||
>>> verify_id = client.auth.login("960", "7777777")
|
>>> verify_id = client.auth.login("960", "7777777")
|
||||||
>>> token = client.auth.verify_otp("123456", verify_id)
|
>>> token = client.auth.verify_otp("123456", verify_id)
|
||||||
@ -50,16 +50,12 @@ class songboxClient:
|
|||||||
|
|
||||||
if headers:
|
if headers:
|
||||||
default_headers.update(headers)
|
default_headers.update(headers)
|
||||||
|
|
||||||
# Create HTTP client
|
|
||||||
self._http_client = httpx.Client(
|
self._http_client = httpx.Client(
|
||||||
base_url=self.base_url,
|
base_url=self.base_url,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
headers=default_headers,
|
headers=default_headers,
|
||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize sub-clients
|
|
||||||
self.auth = AuthClient(self)
|
self.auth = AuthClient(self)
|
||||||
self.user = UserClient(self)
|
self.user = UserClient(self)
|
||||||
self.music = MusicClient(self)
|
self.music = MusicClient(self)
|
||||||
@ -120,7 +116,6 @@ class songboxClient:
|
|||||||
if require_auth and not self.is_authenticated:
|
if require_auth and not self.is_authenticated:
|
||||||
raise songboxError("Authentication required for this endpoint")
|
raise songboxError("Authentication required for this endpoint")
|
||||||
|
|
||||||
# Prepare request
|
|
||||||
url = endpoint if endpoint.startswith('http') else f"/api{endpoint}"
|
url = endpoint if endpoint.startswith('http') else f"/api{endpoint}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -133,18 +128,14 @@ class songboxClient:
|
|||||||
headers=headers
|
headers=headers
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for HTTP errors
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
# Parse JSON response
|
|
||||||
try:
|
try:
|
||||||
return response.json()
|
return response.json()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Handle empty responses
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
# Try to get error message from response
|
|
||||||
try:
|
try:
|
||||||
error_data = e.response.json()
|
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}')
|
||||||
|
136
songbox/music.py
136
songbox/music.py
@ -1,5 +1,9 @@
|
|||||||
"""Music client for songbox API"""
|
"""Music client for songbox API"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import httpx
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from .exceptions import AuthenticationError, NotFoundError, ValidationError
|
from .exceptions import AuthenticationError, NotFoundError, ValidationError
|
||||||
|
|
||||||
@ -43,11 +47,9 @@ class MusicClient:
|
|||||||
require_auth=True
|
require_auth=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ensure we return a list
|
|
||||||
if isinstance(response, list):
|
if isinstance(response, list):
|
||||||
return response
|
return response
|
||||||
elif isinstance(response, dict):
|
elif isinstance(response, dict):
|
||||||
# If response is a dict, try to extract results
|
|
||||||
return response.get('results', response.get('data', []))
|
return response.get('results', response.get('data', []))
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
@ -199,7 +201,47 @@ class MusicClient:
|
|||||||
require_auth=True
|
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_trending(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""Get trending music.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of items to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of trending music items
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationError: If not authenticated
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> trending = client.music.get_trending(limit=10)
|
||||||
|
>>> for item in trending:
|
||||||
|
... print(f"Trending: {item.get('name', 'Unknown')}")
|
||||||
|
"""
|
||||||
|
params = {}
|
||||||
|
if limit is not None:
|
||||||
|
params['limit'] = limit
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._client.request(
|
||||||
|
method="GET",
|
||||||
|
endpoint="/v1/trending",
|
||||||
|
params=params,
|
||||||
|
require_auth=True
|
||||||
|
)
|
||||||
|
|
||||||
if isinstance(response, list):
|
if isinstance(response, list):
|
||||||
return response
|
return response
|
||||||
elif isinstance(response, dict):
|
elif isinstance(response, dict):
|
||||||
@ -233,7 +275,6 @@ class MusicClient:
|
|||||||
require_auth=True
|
require_auth=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ensure we return a list
|
|
||||||
if isinstance(response, list):
|
if isinstance(response, list):
|
||||||
return response
|
return response
|
||||||
elif isinstance(response, dict):
|
elif isinstance(response, dict):
|
||||||
@ -244,4 +285,89 @@ class MusicClient:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
if "401" in str(e) or "unauthorized" in str(e).lower():
|
if "401" in str(e) or "unauthorized" in str(e).lower():
|
||||||
raise AuthenticationError("Authentication required or token expired")
|
raise AuthenticationError("Authentication required or token expired")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def download_song(self, song_url: str, download_path: Optional[str] = None) -> str:
|
||||||
|
"""Download a song from the given URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
song_url: The song URL (typically from song['url_original'])
|
||||||
|
download_path: Optional download directory path. If not provided, uses './downloads'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the downloaded file
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If song_url is empty
|
||||||
|
Exception: If download fails
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> song = client.music.get_song("6200")
|
||||||
|
>>> file_path = client.music.download_song(song['url_original'])
|
||||||
|
>>> print(f"Downloaded to: {file_path}")
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not song_url or not song_url.strip():
|
||||||
|
raise ValidationError("Song URL cannot be empty")
|
||||||
|
if download_path is None:
|
||||||
|
download_path = os.path.join(os.getcwd(), "downloads")
|
||||||
|
|
||||||
|
Path(download_path).mkdir(parents=True, exist_ok=True)
|
||||||
|
webhook_url = f"https://n8n.cubable.date/webhook/songbox?song_url={quote(song_url)}"
|
||||||
|
try:
|
||||||
|
with httpx.Client(follow_redirects=True, timeout=60.0) as download_client:
|
||||||
|
with download_client.stream("GET", webhook_url) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
filename = None
|
||||||
|
if "content-disposition" in response.headers:
|
||||||
|
content_disposition = response.headers["content-disposition"]
|
||||||
|
if "filename=" in content_disposition:
|
||||||
|
filename = content_disposition.split("filename=")[1].strip('"')
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
final_url = str(response.url)
|
||||||
|
if "/" in final_url:
|
||||||
|
filename = final_url.split("/")[-1]
|
||||||
|
if "?" in filename:
|
||||||
|
filename = filename.split("?")[0]
|
||||||
|
|
||||||
|
if not filename or "." not in filename:
|
||||||
|
filename = "song.mp3"
|
||||||
|
|
||||||
|
if not any(filename.lower().endswith(ext) for ext in [".mp3", ".m4a", ".wav", ".flac"]):
|
||||||
|
filename += ".mp3"
|
||||||
|
|
||||||
|
file_path = os.path.join(download_path, filename)
|
||||||
|
|
||||||
|
total_size = int(response.headers.get("content-length", 0))
|
||||||
|
downloaded_size = 0
|
||||||
|
|
||||||
|
print(f"Downloading: {filename}")
|
||||||
|
if total_size > 0:
|
||||||
|
print(f"File size: {total_size / (1024*1024):.2f} MB")
|
||||||
|
|
||||||
|
with open(file_path, "wb") as file:
|
||||||
|
for chunk in response.iter_bytes(chunk_size=8192):
|
||||||
|
if chunk:
|
||||||
|
file.write(chunk)
|
||||||
|
downloaded_size += len(chunk)
|
||||||
|
|
||||||
|
if total_size > 0:
|
||||||
|
progress = (downloaded_size / total_size) * 100
|
||||||
|
bar_length = 50
|
||||||
|
filled_length = int(bar_length * downloaded_size // total_size)
|
||||||
|
bar = "█" * filled_length + "-" * (bar_length - filled_length)
|
||||||
|
print(f"\r[{bar}] {progress:.1f}% ({downloaded_size / (1024*1024):.2f}/{total_size / (1024*1024):.2f} MB)", end="")
|
||||||
|
else:
|
||||||
|
print(f"\rDownloaded: {downloaded_size / (1024*1024):.2f} MB", end="")
|
||||||
|
|
||||||
|
print(f"\nDownload completed: {file_path}")
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise Exception(f"Failed to download song: HTTP {e.response.status_code}")
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
raise Exception(f"Failed to download song: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Download failed: {str(e)}")
|
Reference in New Issue
Block a user