megaloader 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ exclude tests/*
2
+ prune tests
@@ -0,0 +1,213 @@
1
+ Metadata-Version: 2.4
2
+ Name: megaloader
3
+ Version: 0.1.0
4
+ Summary: Python library for extracting downloadable content metadata from file hosting platforms
5
+ Author-email: DURAND Malo <malo.durand@epitech.eu>
6
+ Maintainer-email: David Duran <dadch1404@gmail.com>
7
+ License-Expression: Apache-2.0
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: beautifulsoup4>=4.14.2
11
+ Requires-Dist: lxml>=6.0.2
12
+ Requires-Dist: requests>=2.32.5
13
+ Provides-Extra: dev
14
+ Requires-Dist: mypy>=1.18.2; extra == "dev"
15
+ Requires-Dist: pytest>=8.4.2; extra == "dev"
16
+ Requires-Dist: requests-mock>=1.12.1; extra == "dev"
17
+ Requires-Dist: types-beautifulsoup4>=4.12.0.20250516; extra == "dev"
18
+ Requires-Dist: types-requests>=2.32.0; extra == "dev"
19
+
20
+ # [pkg]: megaloader (core)
21
+
22
+ [![PyPI version](https://badge.fury.io/py/megaloader.svg)](https://badge.fury.io/py/megaloader)
23
+ [![CodeQL](https://github.com/totallynotdavid/megaloader/actions/workflows/codeql.yml/badge.svg)](https://github.com/totallynotdavid/megaloader/actions/workflows/codeql.yml)
24
+ [![lint and format check](https://github.com/totallynotdavid/megaloader/actions/workflows/checks.yml/badge.svg)](https://github.com/totallynotdavid/megaloader/actions/workflows/checks.yml)
25
+ [![codecov](https://codecov.io/gh/totallynotdavid/megaloader/graph/badge.svg?token=SBHAGJJB8L)](https://codecov.io/gh/totallynotdavid/megaloader)
26
+
27
+ Library for extracting downloadable content metadata from file hosting
28
+ platforms. Provides automatic URL detection and a plugin architecture for
29
+ multi-platform support.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install megaloader
35
+ ```
36
+
37
+ The library has minimal dependencies: `requests` for HTTP, `beautifulsoup4` and
38
+ `lxml` for HTML parsing.
39
+
40
+ ## Basic usage
41
+
42
+ Call `extract()` with any supported URL. The function detects the platform
43
+ automatically and returns a generator of file metadata:
44
+
45
+ ```python
46
+ from megaloader import extract
47
+
48
+ for item in extract("https://pixeldrain.com/l/abc123"):
49
+ print(f"{item.filename} - {item.download_url}")
50
+ ```
51
+
52
+ Each item contains the download URL, filename, and optional metadata like
53
+ collection name and file size. Network requests happen lazily during iteration.
54
+
55
+ ## Downloading files
56
+
57
+ The library extracts metadata only. Use `requests` or similar to download:
58
+
59
+ ```python
60
+ import requests
61
+ from pathlib import Path
62
+ from megaloader import extract
63
+
64
+ def download_file(item, output_dir):
65
+ dest = Path(output_dir) / item.filename
66
+ response = requests.get(item.download_url, headers=item.headers, stream=True)
67
+ response.raise_for_status()
68
+
69
+ with open(dest, 'wb') as f:
70
+ for chunk in response.iter_content(chunk_size=8192):
71
+ f.write(chunk)
72
+
73
+ for item in extract("https://pixeldrain.com/l/abc123"):
74
+ download_file(item, "./downloads")
75
+ ```
76
+
77
+ The `headers` attribute contains any required HTTP headers for the download
78
+ request.
79
+
80
+ ## Supported platforms
81
+
82
+ Four core platforms receive active development. Seven extended platforms are
83
+ maintained best-effort and may break without immediate fixes.
84
+
85
+ **Core platforms**:
86
+
87
+ Bunkr (bunkr.si, bunkr.la, bunkr.is, bunkr.ru, bunkr.su), PixelDrain
88
+ (pixeldrain.com), Cyberdrop (cyberdrop.cr, cyberdrop.me, cyberdrop.to), GoFile
89
+ (gofile.io).
90
+
91
+ **Extended platforms**:
92
+
93
+ Fanbox ({creator}.fanbox.cc), Pixiv (pixiv.net), Rule34 (rule34.xxx), ThotsLife
94
+ (thotslife.com), ThotHub.VIP (thothub.vip), ThotHub.TO (thothub.to), Fapello
95
+ (fapello.com).
96
+
97
+ All platforms support albums, galleries, or lists. Single-file URLs work where
98
+ applicable.
99
+
100
+ Extended platforms marked as working as of November 2025.
101
+
102
+ ## Platform-specific features
103
+
104
+ GoFile supports password-protected folders through the password parameter:
105
+
106
+ ```python
107
+ items = extract("https://gofile.io/d/folder", password="secret123")
108
+ ```
109
+
110
+ Fanbox and Pixiv require session cookies for full results. Without
111
+ authentication, only limited data is returned:
112
+
113
+ ```python
114
+ items = extract("https://creator.fanbox.cc", session_id="your_session_cookie")
115
+ items = extract("https://pixiv.net/artworks/12345", session_id="your_session_cookie")
116
+ ```
117
+
118
+ Rule34 accepts optional API credentials for higher rate limits:
119
+
120
+ ```python
121
+ items = extract(
122
+ "https://rule34.xxx/index.php?page=post&s=list&tags=example",
123
+ api_key="your_api_key",
124
+ user_id="your_user_id"
125
+ )
126
+ ```
127
+
128
+ Authentication improves results but is not required.
129
+
130
+ <!-- prettier-ignore -->
131
+ > [!WARNING]
132
+ > Free-tier accounts on Pixiv and Fanbox may still return incomplete file sets.
133
+
134
+ ## Working with items
135
+
136
+ The `DownloadItem` dataclass contains file metadata:
137
+
138
+ ```python
139
+ for item in extract(url):
140
+ item.download_url # Direct download URL (required)
141
+ item.filename # Original filename (required)
142
+ item.collection_name # Album/gallery name (optional)
143
+ item.source_id # Platform-specific ID (optional)
144
+ item.size_bytes # File size in bytes (optional)
145
+ item.headers # Required HTTP headers (optional)
146
+ ```
147
+
148
+ Required fields are always populated. Optional fields may be `None` depending on
149
+ platform and content type.
150
+
151
+ ## Direct plugin usage
152
+
153
+ Import plugin classes directly when you need fine-grained control or want to
154
+ force a specific plugin:
155
+
156
+ ```python
157
+ from megaloader.plugins import Cyberdrop
158
+
159
+ plugin = Cyberdrop("https://cyberdrop.me/a/album_id")
160
+ items = list(plugin.extract())
161
+ print(f"Found {len(items)} files")
162
+ ```
163
+
164
+ This bypasses automatic detection. Useful when a platform introduces new domains
165
+ before the package updates.
166
+
167
+ ## Error handling
168
+
169
+ Handle extraction failures as needed:
170
+
171
+ ```python
172
+ from megaloader import extract, ExtractionError, UnsupportedDomainError
173
+
174
+ try:
175
+ items = list(extract(url))
176
+ except UnsupportedDomainError:
177
+ print("Platform not supported")
178
+ except ExtractionError as e:
179
+ print(f"Extraction failed: {e}")
180
+ except ValueError:
181
+ print("Invalid URL format")
182
+ ```
183
+
184
+ Network failures raise `ExtractionError`. Unsupported URLs raise
185
+ `UnsupportedDomainError`. Malformed URLs raise `ValueError`.
186
+
187
+ ## API reference
188
+
189
+ The `extract()` function takes a URL and platform-specific options. Returns a
190
+ generator of `DownloadItem` objects. Raises `ValueError` for invalid URLs,
191
+ `UnsupportedDomainError` when no plugin exists, `ExtractionError` on network or
192
+ parsing failures.
193
+
194
+ The `DownloadItem` dataclass has required fields `download_url` and `filename`.
195
+ Optional fields are `collection_name`, `source_id`, `size_bytes`, and `headers`.
196
+
197
+ The `BasePlugin` abstract class defines the plugin interface. Override
198
+ `extract()` to yield items. Override `_configure_session()` to add custom
199
+ headers or authentication. The `session` property provides a configured requests
200
+ session. The `url` and `options` properties contain constructor arguments.
201
+
202
+ Exception hierarchy: `ExtractionError` for network and parsing failures,
203
+ `UnsupportedDomainError` for unknown domains, both inherit from `Exception`.
204
+
205
+ ## Contributing
206
+
207
+ The project welcomes contributions. Install dependencies with `uv sync` from the
208
+ repository root. Run `uv run ruff format .` and `uv run mypy packages/core`
209
+ before committing. See the repository contributing guide for plugin development
210
+ details.
211
+
212
+ Report bugs and request features through GitHub Discussions. Include Python
213
+ version, error messages, and problematic URLs.
@@ -0,0 +1,87 @@
1
+ """
2
+ Megaloader - Extract downloadable content metadata from file hosting platforms.
3
+
4
+ Basic usage:
5
+ import megaloader as mgl
6
+
7
+ for item in mgl.extract(url):
8
+ print(item.url, item.filename)
9
+
10
+ # With plugin-specific options
11
+ items = mgl.extract(url, password="secret")
12
+ items = mgl.extract(url, session_id="cookie_value")
13
+ """
14
+
15
+ import logging
16
+ import urllib.parse
17
+
18
+ from collections.abc import Generator
19
+ from typing import Any
20
+
21
+ from megaloader.exceptions import ExtractionError, UnsupportedDomainError
22
+ from megaloader.item import DownloadItem
23
+ from megaloader.plugins import get_plugin_class
24
+
25
+
26
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
27
+ logger = logging.getLogger(__name__)
28
+
29
+ __version__ = "0.2.0"
30
+ __all__ = ["DownloadItem", "ExtractionError", "UnsupportedDomainError", "extract"]
31
+
32
+
33
+ def extract(url: str, **options: Any) -> Generator[DownloadItem, None, None]:
34
+ """
35
+ Extract downloadable items from a URL.
36
+
37
+ Returns a generator that yields items lazily as they're discovered.
38
+ Network requests happen during iteration, not at call time.
39
+
40
+ Args:
41
+ url: The source URL to extract from
42
+ **options: Plugin-specific options:
43
+ - password: str (Gofile)
44
+ - session_id: str (Fanbox, Pixiv)
45
+ - api_key: str (Rule34)
46
+ - user_id: str (Rule34)
47
+
48
+ Yields:
49
+ DownloadItem: Metadata for each downloadable file
50
+
51
+ Raises:
52
+ ValueError: Invalid URL format
53
+ UnsupportedDomainError: No plugin available for domain
54
+ ExtractionError: Network or parsing failure
55
+
56
+ Example:
57
+ >>> for item in extract("https://pixeldrain.com/l/abc123"):
58
+ ... print(item.download_url, item.filename)
59
+ """
60
+ if not url or not url.strip():
61
+ msg = "URL cannot be empty"
62
+ raise ValueError(msg)
63
+
64
+ url = url.strip()
65
+ parsed = urllib.parse.urlparse(url)
66
+
67
+ if not parsed.netloc:
68
+ msg = f"Invalid URL: Could not parse domain from '{url}'"
69
+ raise ValueError(msg)
70
+
71
+ plugin_class = get_plugin_class(parsed.netloc)
72
+ if plugin_class is None:
73
+ raise UnsupportedDomainError(parsed.netloc)
74
+
75
+ logger.debug(
76
+ "Initializing %s for domain '%s'", plugin_class.__name__, parsed.netloc
77
+ )
78
+
79
+ try:
80
+ plugin = plugin_class(url, **options)
81
+ yield from plugin.extract()
82
+ except (UnsupportedDomainError, ValueError):
83
+ raise
84
+ except Exception as e:
85
+ logger.debug("Extraction failed for %s: %s", url, e, exc_info=True)
86
+ msg = f"Failed to extract from {url}: {e}"
87
+ raise ExtractionError(msg) from e
@@ -0,0 +1,14 @@
1
+ class MegaloaderError(Exception):
2
+ """Base exception for all megaloader errors."""
3
+
4
+
5
+ class ExtractionError(MegaloaderError):
6
+ """Failed to extract items from URL due to network or parsing error."""
7
+
8
+
9
+ class UnsupportedDomainError(MegaloaderError):
10
+ """No plugin available for this domain."""
11
+
12
+ def __init__(self, domain: str) -> None:
13
+ super().__init__(f"No plugin found for domain: {domain}")
14
+ self.domain = domain
@@ -0,0 +1,32 @@
1
+ from dataclasses import dataclass, field
2
+
3
+
4
+ @dataclass
5
+ class DownloadItem:
6
+ """
7
+ Represents a single downloadable file with metadata.
8
+
9
+ Attributes:
10
+ download_url: Direct URL to download the file
11
+ filename: Original filename (may need sanitization for filesystem)
12
+ collection_name: Optional grouping (album/gallery/user)
13
+ source_id: Optional unique identifier from the source platform
14
+ headers: Optional HTTP headers required for download (e.g., Referer)
15
+ size_bytes: Optional file size in bytes
16
+ """
17
+
18
+ download_url: str
19
+ filename: str
20
+ collection_name: str | None = None
21
+ source_id: str | None = None
22
+ headers: dict[str, str] = field(default_factory=dict)
23
+ size_bytes: int | None = None
24
+
25
+ def __post_init__(self) -> None:
26
+ """Validate required fields."""
27
+ if not self.download_url:
28
+ msg = "download_url cannot be empty"
29
+ raise ValueError(msg)
30
+ if not self.filename:
31
+ msg = "filename cannot be empty"
32
+ raise ValueError(msg)
@@ -0,0 +1,94 @@
1
+ import logging
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Generator
5
+ from typing import Any, ClassVar
6
+
7
+ import requests
8
+
9
+ from requests.adapters import HTTPAdapter
10
+ from urllib3.util.retry import Retry
11
+
12
+ from megaloader.item import DownloadItem
13
+
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class BasePlugin(ABC):
19
+ """
20
+ Base class for site-specific extractors.
21
+
22
+ Credential handling convention:
23
+ 1. Explicit **kwargs take precedence (e.g., password="secret")
24
+ 2. Environment variables as fallback (PLUGIN_NAME_*)
25
+ 3. Fail gracefully if required credentials missing
26
+
27
+ Subclasses should override _configure_session() to add:
28
+ - Authentication headers/cookies
29
+ - Site-specific headers (Referer, Origin)
30
+ """
31
+
32
+ DEFAULT_HEADERS: ClassVar[dict[str, str]] = {
33
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
34
+ }
35
+
36
+ def __init__(self, url: str, **options: Any) -> None:
37
+ if not url.strip():
38
+ msg = "URL must be a non-empty string"
39
+ raise ValueError(msg)
40
+
41
+ self.url = url.strip()
42
+ self.options = options
43
+ self._session: requests.Session | None = None
44
+
45
+ @property
46
+ def session(self) -> requests.Session:
47
+ """Lazily create session with retry logic and default headers."""
48
+ if self._session is None:
49
+ self._session = self._create_session()
50
+ self._configure_session(self._session)
51
+ return self._session
52
+
53
+ def _create_session(self) -> requests.Session:
54
+ """Create session with retry strategy for transient failures."""
55
+ session = requests.Session()
56
+ session.headers.update(self.DEFAULT_HEADERS)
57
+
58
+ # Retry on common transient errors
59
+ retry_strategy = Retry(
60
+ total=3,
61
+ backoff_factor=1,
62
+ status_forcelist=[429, 500, 502, 503, 504],
63
+ allowed_methods=["GET", "POST"],
64
+ )
65
+ adapter = HTTPAdapter(max_retries=retry_strategy)
66
+ session.mount("https://", adapter)
67
+ session.mount("http://", adapter)
68
+
69
+ return session
70
+
71
+ def _configure_session(self, session: requests.Session) -> None: # noqa: B027
72
+ """
73
+ Override to add plugin-specific headers/cookies.
74
+
75
+ Example:
76
+ session.headers["Referer"] = f"https://{self.domain}/"
77
+ if api_key := os.getenv("PLUGIN_API_KEY"):
78
+ session.headers["Authorization"] = f"Bearer {api_key}"
79
+ """
80
+
81
+ @abstractmethod
82
+ def extract(self) -> Generator[DownloadItem, None, None]:
83
+ """
84
+ Extract downloadable items from the URL.
85
+
86
+ Yields items as they're discovered (lazy evaluation).
87
+ Should handle pagination, nested galleries, etc.
88
+
89
+ Yields:
90
+ DownloadItem: Each file found at the URL
91
+
92
+ Raises:
93
+ ExtractionError: On network/parsing failures
94
+ """
@@ -0,0 +1,74 @@
1
+ from megaloader.plugin import BasePlugin
2
+ from megaloader.plugins.bunkr import Bunkr
3
+ from megaloader.plugins.cyberdrop import Cyberdrop
4
+ from megaloader.plugins.fanbox import Fanbox
5
+ from megaloader.plugins.fapello import Fapello
6
+ from megaloader.plugins.gofile import Gofile
7
+ from megaloader.plugins.pixeldrain import PixelDrain
8
+ from megaloader.plugins.pixiv import Pixiv
9
+ from megaloader.plugins.rule34 import Rule34
10
+ from megaloader.plugins.thothub_to import ThothubTO
11
+ from megaloader.plugins.thothub_vip import ThothubVIP
12
+ from megaloader.plugins.thotslife import Thotslife
13
+
14
+
15
+ PLUGIN_REGISTRY: dict[str, type[BasePlugin]] = {
16
+ "bunkr.si": Bunkr,
17
+ "bunkr.la": Bunkr,
18
+ "bunkr.is": Bunkr,
19
+ "bunkr.ru": Bunkr,
20
+ "bunkr.su": Bunkr,
21
+ "cyberdrop.cr": Cyberdrop,
22
+ "cyberdrop.me": Cyberdrop,
23
+ "cyberdrop.to": Cyberdrop,
24
+ "fanbox.cc": Fanbox,
25
+ "fapello.com": Fapello,
26
+ "gofile.io": Gofile,
27
+ "pixeldrain.com": PixelDrain,
28
+ "pixiv.net": Pixiv,
29
+ "rule34.xxx": Rule34,
30
+ "thothub.ch": ThothubTO,
31
+ "thothub.to": ThothubTO,
32
+ "thothub.vip": ThothubVIP,
33
+ "thotslife.com": Thotslife,
34
+ }
35
+
36
+ # Domains that support subdomains (e.g., creator.fanbox.cc)
37
+ SUBDOMAIN_SUPPORTED: set[str] = {"fanbox.cc"}
38
+
39
+
40
+ def get_plugin_class(domain: str) -> type[BasePlugin] | None:
41
+ """
42
+ Resolve domain to plugin class.
43
+
44
+ Resolution order:
45
+ 1. Exact match in PLUGIN_REGISTRY
46
+ 2. Subdomain match for supported domains
47
+ 3. Partial match (fallback for domain variations)
48
+
49
+ Args:
50
+ domain: Normalized domain from URL (e.g., "pixiv.net")
51
+
52
+ Returns:
53
+ Plugin class or None if unsupported
54
+ """
55
+ domain = domain.lower().strip()
56
+
57
+ # Exact match
58
+ if domain in PLUGIN_REGISTRY:
59
+ return PLUGIN_REGISTRY[domain]
60
+
61
+ # Subdomain support (e.g., creator.fanbox.cc -> fanbox.cc)
62
+ for base_domain in SUBDOMAIN_SUPPORTED:
63
+ if domain.endswith(f".{base_domain}") and base_domain in PLUGIN_REGISTRY:
64
+ return PLUGIN_REGISTRY[base_domain]
65
+
66
+ # Partial match fallback (e.g., www.pixiv.net -> pixiv.net)
67
+ for registered_domain, plugin_class in PLUGIN_REGISTRY.items():
68
+ if registered_domain in domain:
69
+ return plugin_class
70
+
71
+ return None
72
+
73
+
74
+ __all__ = ["PLUGIN_REGISTRY", "get_plugin_class"]
@@ -0,0 +1,147 @@
1
+ import base64
2
+ import html
3
+ import logging
4
+ import math
5
+ import re
6
+
7
+ from collections.abc import Generator
8
+ from urllib.parse import quote, urljoin, urlparse
9
+
10
+ import requests
11
+
12
+ from megaloader.item import DownloadItem
13
+ from megaloader.plugin import BasePlugin
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class Bunkr(BasePlugin):
20
+ """Extract files from Bunkr albums and individual file pages."""
21
+
22
+ API_BASE = "https://apidl.bunkr.ru/api/_001_v2"
23
+
24
+ def extract(self) -> Generator[DownloadItem, None, None]:
25
+ path = urlparse(self.url).path
26
+
27
+ if path.startswith("/a/"):
28
+ logger.debug("Processing album")
29
+ yield from self._extract_album()
30
+ elif path.startswith("/f/"):
31
+ logger.debug("Processing single file")
32
+ yield from self._extract_file(self.url)
33
+ else:
34
+ logger.warning("Unrecognized Bunkr URL format")
35
+
36
+ def _extract_album(self) -> Generator[DownloadItem, None, None]:
37
+ """Extract all files from an album page."""
38
+ try:
39
+ response = self.session.get(self.url, allow_redirects=True, timeout=30)
40
+ response.raise_for_status()
41
+ except Exception:
42
+ logger.exception("Failed to fetch album page")
43
+ return
44
+
45
+ file_links = re.findall(r'href="(/f/[^"]+)"', response.text)
46
+
47
+ if not file_links:
48
+ logger.warning("No files found in album")
49
+ return
50
+
51
+ seen_urls = set()
52
+ for link in file_links:
53
+ # Skip template variables
54
+ if "file.slug" in link or "+" in link:
55
+ continue
56
+
57
+ file_url = urljoin(response.url, link)
58
+ if file_url in seen_urls:
59
+ continue
60
+
61
+ seen_urls.add(file_url)
62
+ yield from self._extract_file(file_url)
63
+
64
+ def _extract_file(self, file_url: str) -> Generator[DownloadItem, None, None]:
65
+ """Extract download URL from a file page."""
66
+ try:
67
+ response = self.session.get(file_url, timeout=30)
68
+ response.raise_for_status()
69
+ except requests.RequestException:
70
+ logger.debug("Failed to fetch file page %s", file_url, exc_info=True)
71
+ return
72
+
73
+ # Find download button
74
+ download_match = re.search(
75
+ r'<a[^>]+class="[^"]*btn-main[^"]*"[^>]+href="([^"]+)"[^>]*>Download</a>',
76
+ response.text,
77
+ )
78
+
79
+ if not download_match:
80
+ logger.debug("No download button found for %s", file_url)
81
+ return
82
+
83
+ download_page_url = urljoin(file_url, download_match.group(1))
84
+
85
+ # Extract file ID from download page URL
86
+ if match := re.search(r"/file/(\w+)", download_page_url):
87
+ file_id = match.group(1)
88
+ else:
89
+ logger.debug("Could not extract file ID from %s", download_page_url)
90
+ return
91
+
92
+ filename = self._extract_filename(response.text) or f"bunkr_file_{file_id}"
93
+
94
+ if direct_url := self._fetch_direct_url(file_id, filename):
95
+ yield DownloadItem(
96
+ download_url=direct_url,
97
+ filename=filename,
98
+ source_id=file_id,
99
+ headers={"Referer": "https://get.bunkrr.su/"},
100
+ )
101
+
102
+ def _extract_filename(self, content: str) -> str | None:
103
+ """Extract original filename from page metadata."""
104
+ # Try og:title meta tag
105
+ if match := re.search(r'<meta property="og:title" content="([^"]+)"', content):
106
+ return html.unescape(match.group(1)).strip()
107
+
108
+ # Try JavaScript variable
109
+ if match := re.search(r'var ogname\s*=\s*"([^"]+)"', content):
110
+ return html.unescape(match.group(1)).strip()
111
+
112
+ return None
113
+
114
+ def _fetch_direct_url(self, file_id: str, filename: str) -> str | None:
115
+ """Get direct CDN URL using Bunkr's API."""
116
+ try:
117
+ response = self.session.post(
118
+ self.API_BASE,
119
+ json={"id": file_id},
120
+ timeout=30,
121
+ )
122
+ response.raise_for_status()
123
+ data = response.json()
124
+
125
+ # Decrypt the URL
126
+ timestamp = data["timestamp"]
127
+ encrypted_b64 = data["url"]
128
+
129
+ # Generate time-based decryption key
130
+ key_str = f"SECRET_KEY_{math.floor(timestamp / 3600)}"
131
+ key_bytes = key_str.encode("utf-8")
132
+
133
+ # XOR decrypt
134
+ encrypted_bytes = base64.b64decode(encrypted_b64)
135
+ decrypted = bytearray(
136
+ encrypted_bytes[i] ^ key_bytes[i % len(key_bytes)]
137
+ for i in range(len(encrypted_bytes))
138
+ )
139
+
140
+ base_url = decrypted.decode("utf-8")
141
+ return f"{base_url}?n={quote(filename)}"
142
+
143
+ except Exception: # noqa: BLE001
144
+ logger.debug(
145
+ "Failed to fetch direct URL for file ID %s", file_id, exc_info=True
146
+ )
147
+ return None