diff --git a/.github/workflows/build_release.yml b/.github/workflows/build_release.yml new file mode 100644 index 0000000..8b68881 --- /dev/null +++ b/.github/workflows/build_release.yml @@ -0,0 +1,33 @@ +name: Build and Release + +on: + push: + tags: + - 'v*' + paths-ignore: + - "README.md" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.9" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + + - name: Build package + run: python -m build + + - name: Archive artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2810fb5 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# GofilePy + +A Python library and CLI tool for [Gofile.io](https://gofile.io). +It supports the free API tiers, streaming uploads (low memory usage for large files), and script-friendly JSON output. + +## Features + +- 🚀 **Streaming Uploads**: Upload 100GB+ files without loading them into RAM. +- 📂 **Folder Management**: Upload to specific folders or create new ones automatically. +- 🤖 **Script Ready**: JSON output mode for easy parsing in pipelines. +- 🆓 **Free Tier Support**: Handles Guest accounts and Standard tokens. +- 📊 **Progress Bar**: Visual feedback for long uploads. + +## Installation + +### From Source + +1. Clone the repository. +2. Install via pip: + +```bash +pip install . +``` + +## Usage (CLI) + +### Basic Upload +Upload a single file. A new public folder will be created automatically if you don't provide one. + +```bash +gofilepy video.mp4 +``` + +### Upload with Token +Export your token (Get it from your Gofile Profile) to access your account storage. + +```bash +export GOFILE_TOKEN="your_token_here" +gofilepy my_file.zip +``` + +### Upload to a Specific Folder +If you have an existing folder ID: + +```bash +gofilepy -f "folder-uuid-123" image.png +``` + +### Group Upload (Single Folder) +Upload multiple files. The first file creates a folder, and the rest are uploaded into it. + +```bash +gofilepy -s part1.rar part2.rar part3.rar +``` + +### Scripting Mode (JSON Output) +Use `--json` to suppress human-readable text and output a JSON array. + +```bash +gofilepy --json file.txt +# Output: [{"file": "file.txt", "status": "success", "downloadPage": "...", ...}] +``` + +### Verbose Mode +Debug connection issues or API responses. + +```bash +gofilepy -vv big_file.iso +``` + +## Usage (Library) + +You can use `gofilepy` in your own Python scripts. + +```python +from gofilepy import GofileClient + +# Initialize (Token optional for guest upload) +client = GofileClient(token="your_token") + +# Simple Callback for progress +def my_progress(bytes_read): + print(f"Read: {bytes_read} bytes") + +# Upload (Streaming) +try: + response = client.upload_file( + file_path="/path/to/movie.mkv", + folder_id=None, # None = Create new folder + callback=my_progress + ) + print(f"Uploaded! Download here: {response['downloadPage']}") +except Exception as e: + print(f"Error: {e}") +``` + +## Building for Release + +To build a `.whl` (Wheel) file and a source distribution: + +1. Install build tools: + ```bash + pip install build twine + ``` +2. Run build: + ```bash + python -m build + ``` +3. Artifacts will be in `dist/`. + +## License + +This project is licensed under the [MIT](LICENSE) License. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b47f66f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "gofilepy" +version = "1.0.0" +description = "A Python CLI and Library for gofile.io" +readme = "README.md" +authors = [{ name = "Garnajee", email = "62147746+garnajee@users.noreply.github.com" }] +license = { text = "MIT" } +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] +requires-python = ">=3.7" +dependencies = [ + "httpx", # The modern HTTP client + "tqdm" # Progress bar +] + +[project.scripts] +gofilepy = "gofilepy.cli:main" diff --git a/src/gofilepy/__init__.py b/src/gofilepy/__init__.py new file mode 100644 index 0000000..05cd026 --- /dev/null +++ b/src/gofilepy/__init__.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 + +from .client import GofileClient + +__version__ = "1.0.0" +__all__ = ["GofileClient"] + diff --git a/src/gofilepy/cli.py b/src/gofilepy/cli.py new file mode 100644 index 0000000..2bbf60a --- /dev/null +++ b/src/gofilepy/cli.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 + +import argparse +import os +import json +import logging +from tqdm import tqdm +from .client import GofileClient + +# Configure Logging +logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(message)s') +logger = logging.getLogger("gofilepy") + +def main(): + parser = argparse.ArgumentParser(description="Gofile.io CLI Uploader (HTTPX Edition)") + + parser.add_argument("files", nargs='+', help="Files to upload") + + parser.add_argument("-s", "--to-single-folder", action="store_true", + help="Upload multiple files to the same folder.") + + parser.add_argument("-f", "--folder-id", type=str, default=None, + help="ID of an existing Gofile folder.") + + parser.add_argument("-vv", "--verbose", action="store_true", + help="Show detailed debug info.") + + parser.add_argument("--json", action="store_true", + help="Output result as JSON for scripts.") + + args = parser.parse_args() + + # Log Level Handling + if args.verbose: + logger.setLevel(logging.DEBUG) + # HTTPX can be verbose, enable if needed + # logging.getLogger("httpx").setLevel(logging.DEBUG) + else: + logger.setLevel(logging.INFO) + logging.getLogger("httpx").setLevel(logging.WARNING) + + # Token Logic + token = os.environ.get("GOFILE_TOKEN") + if not token and args.to_single_folder and not args.folder_id: + logger.debug("No token found. Proceeding as Guest.") + + client = GofileClient(token=token) + + target_folder_id = args.folder_id + results = [] + + for file_path in args.files: + if not os.path.exists(file_path): + res_err = {"file": file_path, "status": "error", "message": "File not found"} + results.append(res_err) + if not args.json: + logger.error(f"File not found: {file_path}") + continue + + file_size = os.path.getsize(file_path) + filename = os.path.basename(file_path) + + # Init Progress Bar (Only if not JSON mode) + pbar = None + if not args.json: + pbar = tqdm(total=file_size, unit='B', unit_scale=True, desc=f"Uploading {filename}") + + def progress_update(chunk_size): + if pbar: + pbar.update(chunk_size) + + try: + data = client.upload_file( + file_path=file_path, + folder_id=target_folder_id, + callback=progress_update + ) + + # --- Auto-Folder Management for Guests --- + # If we are in single folder mode and it's the first upload + if args.to_single_folder and target_folder_id is None: + if 'parentFolder' in data: + target_folder_id = data['parentFolder'] + logger.debug(f"Parent folder set to: {target_folder_id}") + + # If guest, capture the guestToken to write to the same folder next time + if 'guestToken' in data and not client.token: + client.token = data['guestToken'] + # Re-auth client with new token + client.client.headers.update({"Authorization": f"Bearer {client.token}"}) + logger.debug(f"Guest token applied: {client.token}") + + results.append({ + "file": filename, + "status": "success", + "downloadPage": data.get("downloadPage"), + "directLink": data.get("directLink", "N/A"), # Sometimes available + "parentFolder": data.get("parentFolder") + }) + + except Exception as e: + err_msg = str(e) + results.append({"file": filename, "status": "error", "message": err_msg}) + if not args.json: + logger.error(f"Upload failed: {err_msg}") + finally: + if pbar: + pbar.close() + + # Output + if args.json: + print(json.dumps(results, indent=2)) + else: + print("\n--- Summary ---") + for res in results: + if res['status'] == 'success': + print(f"✅ {res['file']} -> {res['downloadPage']}") + else: + print(f"❌ {res['file']} -> {res['message']}") + +if __name__ == "__main__": + main() diff --git a/src/gofilepy/client.py b/src/gofilepy/client.py new file mode 100644 index 0000000..ce8ee56 --- /dev/null +++ b/src/gofilepy/client.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 + +import httpx +import logging +import os +from typing import Optional, List, Dict, Callable +from .utils import ProgressFileReader + +logger = logging.getLogger(__name__) + +class GofileClient: + API_ROOT = "https://api.gofile.io" + UPLOAD_SERVER_URL = "https://upload.gofile.io" + + def __init__(self, token: Optional[str] = None): + self.token = token + # Increase timeout for large API operations, though uploads handle their own timeout + self.client = httpx.Client(timeout=30.0) + + if self.token: + logger.debug(f"Initialized with token: {self.token[:4]}***") + self.client.headers.update({"Authorization": f"Bearer {self.token}"}) + + def _handle_response(self, response: httpx.Response) -> Dict: + try: + data = response.json() + except Exception: + logger.error(f"Failed to parse JSON: {response.text}") + response.raise_for_status() + return {} + + if data.get("status") != "ok": + logger.error(f"API Error: {data}") + raise Exception(f"Gofile API Error: {data.get('status')} - {data.get('data')}") + + return data.get("data", {}) + + def get_server(self) -> str: + """ + Gofile suggests using specific servers (availables in their doc), + but 'upload.gofile.io' uses DNS geo-routing automatically. + We stick to the best practice default. + """ + return self.UPLOAD_SERVER_URL + + def create_folder(self, parent_folder_id: str, folder_name: str) -> Dict: + logger.debug(f"Creating folder '{folder_name}' in '{parent_folder_id}'") + url = f"{self.API_ROOT}/contents/createFolder" + payload = { + "parentFolderId": parent_folder_id, + "folderName": folder_name + } + res = self.client.post(url, json=payload) + return self._handle_response(res) + + def delete_content(self, content_ids: List[str]) -> Dict: + logger.debug(f"Deleting content IDs: {content_ids}") + url = f"{self.API_ROOT}/contents" + # HTTPX needs 'content' or 'json' for DELETE requests explicitly if body is required + res = self.client.request("DELETE", url, json={"contentsId": ",".join(content_ids)}) + return self._handle_response(res) + + def upload_file(self, + file_path: str, + folder_id: Optional[str] = None, + callback: Optional[Callable[[int], None]] = None) -> Dict: + + server_url = f"{self.get_server()}/uploadfile" + file_name = os.path.basename(file_path) + + # Prepare parameters + data = {} + if self.token: + data["token"] = self.token + if folder_id: + data["folderId"] = folder_id + + # Use our custom ProgressFileReader + # If no callback is provided, we use a dummy lambda to avoid errors + progress_callback = callback if callback else lambda x: None + + logger.info(f"Starting upload: {file_name} -> {server_url}") + + # Open file using our wrapper + with ProgressFileReader(file_path, progress_callback) as f: + files = {'file': (file_name, f)} + + # Use a longer timeout for the upload specifically (None = infinite) + # This is crucial for 2000GB files + res = self.client.post( + server_url, + data=data, + files=files, + timeout=None + ) + + return self._handle_response(res) diff --git a/src/gofilepy/utils.py b/src/gofilepy/utils.py new file mode 100644 index 0000000..03157fa --- /dev/null +++ b/src/gofilepy/utils.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 + +import typing +import io + +class ProgressFileReader(io.BufferedReader): + """ + Wraps a file object to trigger a callback when data is read. + This allows monitoring upload progress in httpx without loading the file into RAM. + """ + def __init__(self, filename: str, callback: typing.Callable[[int], None]): + self._f = open(filename, 'rb') + self._callback = callback + # Get file size for verification if needed, or just standard init + super().__init__(self._f) + + def read(self, size: int = -1) -> bytes: + # Read the chunk from disk + chunk = self._f.read(size) + # Update the progress bar with the length of the chunk read + if chunk: + self._callback(len(chunk)) + return chunk + + def close(self) -> None: + if hasattr(self, '_f'): + self._f.close() +