Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,6 @@ SonicBit._complete_tutorial("token")

This will mark the tutorial as completed and allow the user to access their account.


## Contributing

Contributions are welcome! If you find a bug or have a suggestion for a new feature, please open an issue or submit a pull request on the GitHub repository.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ classifiers = [
]
dependencies = [
"httpx>=0.28.1",
"pydantic>=2.12.5",
"pydantic>=2.0.0",
"tenacity>=9.1.4",
]

Expand Down
16 changes: 9 additions & 7 deletions sonicbit/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,29 @@
class SonicBitBase:
"""Base class for all SonicBit modules."""

MAX_API_RETRIES = 5
MAX_API_RETRIES = 3
REQUEST_TIMEOUT = 15 # seconds; override at class level if needed

def __init__(self):
transport = httpx.HTTPTransport(retries=3)
self.session = httpx.Client(transport=transport)
transport = httpx.HTTPTransport(retries=2)
self.session = httpx.Client(transport=transport, timeout=self.REQUEST_TIMEOUT)

@retry(
stop=stop_after_attempt(MAX_API_RETRIES),
wait=wait_exponential(multiplier=1, min=4, max=10),
retry=retry_if_exception_type(httpx.ConnectError),
wait=wait_exponential(multiplier=1, min=1, max=5),
retry=retry_if_exception_type((httpx.ConnectError, httpx.TimeoutException)),
)
def _request(self, *args, **kwargs):
return self.session.request(*args, **kwargs)

@staticmethod
@retry(
stop=stop_after_attempt(MAX_API_RETRIES),
wait=wait_exponential(multiplier=1, min=4, max=10),
retry=retry_if_exception_type(httpx.ConnectError),
wait=wait_exponential(multiplier=1, min=1, max=5),
retry=retry_if_exception_type((httpx.ConnectError, httpx.TimeoutException)),
)
def _static_request(*args, **kwargs):
kwargs.setdefault("timeout", SonicBitBase.REQUEST_TIMEOUT)
return httpx.request(*args, **kwargs)

@staticmethod
Expand Down
4 changes: 3 additions & 1 deletion sonicbit/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ def __init__(
email: str,
password: str,
token: str | None = None,
token_handler: TokenHandler = TokenFileHandler(),
token_handler: TokenHandler | None = None,
):
if token_handler is None:
token_handler = TokenFileHandler()
super().__init__(email, password, token, token_handler)
30 changes: 25 additions & 5 deletions sonicbit/handlers/token_handler.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
from abc import ABC, abstractmethod

from sonicbit.models.auth_response import AuthResponse


class TokenHandler:
def __init__(self):
pass
class TokenHandler(ABC):
"""Abstract base class for token storage backends.

Subclass this and pass an instance to SonicBit() to store tokens in
a database, secrets manager, or any other medium.
"""

@abstractmethod
def write(self, email: str, auth: AuthResponse) -> None:
print(f"{email}'s token is {auth.token}")
"""Persist the token returned after a successful login.

Args:
email: The account email used to key the stored token.
auth: The AuthResponse containing the new token.
"""

@abstractmethod
def read(self, email: str) -> str | None:
return input(f"Enter {email}'s token: ")
"""Return a previously persisted token, or None if absent.

Args:
email: The account email to look up.

Returns:
A token string if one is cached, otherwise None so the SDK
falls back to a fresh login.
"""
10 changes: 8 additions & 2 deletions sonicbit/models/remote_download/remote_task_list.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from datetime import datetime
from json import JSONDecodeError

from httpx import Response
from pydantic import BaseModel, ConfigDict, Field

from sonicbit.base import SonicBitBase
from sonicbit.errors import SonicBitError
from sonicbit.errors import InvalidResponseError, SonicBitError
from sonicbit.models.path_info import PathInfo

from .remote_task import RemoteTask
Expand All @@ -19,7 +20,12 @@ class RemoteTaskList(BaseModel):

@staticmethod
def from_response(client: SonicBitBase, response: Response) -> "RemoteTaskList":
json_data = response.json()
try:
json_data = response.json()
except JSONDecodeError:
raise InvalidResponseError(
f"Server returned invalid JSON data: {response.text}"
) from None

if "message" in json_data:
raise SonicBitError(
Expand Down
12 changes: 5 additions & 7 deletions sonicbit/modules/auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import threading

from sonicbit.base import SonicBitBase
from sonicbit.constants import Constants
Expand All @@ -17,7 +18,7 @@ def __init__(
token_handler: TokenHandler,
):
super().__init__()
self._refreshing = False
self._refresh_lock = threading.Lock() # prevents concurrent token refreshes
logger.debug("Initializing auth for email=%s", email)
self._email = email
self._password = password
Expand Down Expand Up @@ -49,14 +50,11 @@ def _refresh_token(self) -> str:
def _request(self, *args, **kwargs):
response = super()._request(*args, **kwargs)

if response.status_code == 401 and not self._refreshing:
logger.debug("Received 401, refreshing token for email=%s", self._email)
self._refreshing = True
try:
if response.status_code == 401:
with self._refresh_lock:
logger.debug("Received 401, refreshing token for email=%s", self._email)
self._refresh_token()
response = super()._request(*args, **kwargs)
finally:
self._refreshing = False

return response

Expand Down
50 changes: 23 additions & 27 deletions sonicbit/modules/remote_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,46 +13,42 @@ class RemoteDownload(SonicBitBase):
def add_remote_download(self, url: str, path: PathInfo) -> bool:
logger.debug("Adding remote download url=%s path=%s", url, path.path)

data = {"url": url, "path": path.path}
json_data = self._request(
method="POST",
url=self.url("/remote_download/task/add"),
json={"url": url, "path": path.path},
).json()

reponse = self._request(
method="POST", url=self.url("/remote_download/task/add"), json=data
)

json_data = reponse.json()
if json_data.get("success", False):
return True
if not json_data.get("success", False):
raise SonicBitError(
f"Failed to add remote download: {json_data.get('msg')}"
)

error_message = json_data.get("msg")
if error_message:
raise SonicBitError(f"Failed to add remote download: {error_message}")
return True

def list_remote_downloads(self) -> RemoteTaskList:
logger.debug("Listing all remote downloads")

params = {"action": RemoteDownloadCommand.LIST_REMOTE_DOWNLOADS}
response = self._request(
method="POST", url=self.url("/remote_download/task/list"), params=params
method="POST",
url=self.url("/remote_download/task/list"),
params={"action": RemoteDownloadCommand.LIST_REMOTE_DOWNLOADS},
)

return RemoteTaskList.from_response(self, response)

def delete_remote_download(self, id: int) -> bool:
logger.debug("Deleting remote download id=%s", id)

data = {
"task_id": id,
}
response = self._request(
method="POST", url=self.url("/remote_download/task/delete"), json=data
)

json_data = response.json()
if json_data.get("success", False):
return True
json_data = self._request(
method="POST",
url=self.url("/remote_download/task/delete"),
json={"task_id": id},
).json()

error_message = json_data.get("msg")
if error_message:
raise SonicBitError(f"Failed to delete remote download: {error_message}")
if not json_data.get("success", False):
raise SonicBitError(
f"Failed to delete remote download: {json_data.get('msg')}"
)

return False
return True
5 changes: 2 additions & 3 deletions sonicbit/modules/signup.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def submit_otp(otp: str) -> str:

otp = otp.strip()

if not otp.isdigit() and len(otp) == 6:
if not otp.isdigit() or len(otp) != 6:
raise SonicBitError("OTP must be a 6 digit number")

data = {"code": otp.strip(), "type": "registration", "platform": "Web_Dash_V4"}
Expand Down Expand Up @@ -70,8 +70,7 @@ def _complete_tutorial(token: str) -> bool:

data = {"delete": True}

headers = Constants.API_HEADERS
headers["Authorization"] = f"Bearer {token}"
headers = {**Constants.API_HEADERS, "Authorization": f"Bearer {token}"}

logger.debug("Completing tutorial for token=%s...", token[:8])
response = SonicBitBase._static_request(
Expand Down
25 changes: 14 additions & 11 deletions sonicbit/modules/torrent.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,20 @@ def add_torrent_file(
f"Failed to upload local torrent file: '{local_path}'. File does NOT exist"
)

post_data = {
"command": (None, TorrentCommand.UPLOAD_TORRENT_FILE),
"file": (file_name, open(local_path, "rb"), "application/octet-stream"),
"name": (None, file_name),
"size": (None, str(os.stat(local_path).st_size)),
"auto_start": (None, "1" if auto_start else "0"),
"path": path.path,
}
response = self._request(
method="POST", url=self.url("/app/seedbox/torrent/upload"), files=post_data
)
with open(local_path, "rb") as torrent_file:
post_data = {
"command": (None, TorrentCommand.UPLOAD_TORRENT_FILE),
"file": (file_name, torrent_file, "application/octet-stream"),
"name": (None, file_name),
"size": (None, str(os.stat(local_path).st_size)),
"auto_start": (None, "1" if auto_start else "0"),
"path": (None, path.path),
}
response = self._request(
method="POST",
url=self.url("/app/seedbox/torrent/upload"),
files=post_data,
)
try:
json_data = response.json()
except JSONDecodeError:
Expand Down
26 changes: 13 additions & 13 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.