rightra 0.1.0__py3-none-any.whl

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.
rightra/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """Rightra server-side helpers for Python.
2
+
3
+ Public surface:
4
+
5
+ - Client for the Rightra API.
6
+ - mint_upload_token and verify_upload_token for the PHP upload helper.
7
+ - sign_s3_put and sign_r2_put for direct-to-bucket uploads.
8
+ - RightraError for every failure.
9
+
10
+ No framework imports. Django and Flask users import the same module.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .client import Client
16
+ from .errors import RightraError
17
+ from .storage import sign_r2_put, sign_s3_put
18
+ from .upload import DEFAULT_TTL as UPLOAD_TOKEN_DEFAULT_TTL
19
+ from .upload import mint_upload_token, verify_upload_token
20
+
21
+ __version__ = "0.1.0"
22
+ __all__ = [
23
+ "Client",
24
+ "RightraError",
25
+ "UPLOAD_TOKEN_DEFAULT_TTL",
26
+ "mint_upload_token",
27
+ "verify_upload_token",
28
+ "sign_s3_put",
29
+ "sign_r2_put",
30
+ ]
rightra/client.py ADDED
@@ -0,0 +1,286 @@
1
+ """Rightra API client.
2
+
3
+ Pure urllib. No requests. Every failure raises RightraError.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ import socket
11
+ from typing import Any, Dict, Optional
12
+ from urllib import error as urlerror
13
+ from urllib import request as urlrequest
14
+
15
+ from .errors import RightraError
16
+
17
+ _CONTENT_ID_RE = re.compile(r"^RTR-[A-F0-9]{4}-[A-F0-9]{4}$")
18
+ _DEFAULT_BASE = "https://api.oliviuus.com/api/rightra"
19
+ _MAX_BODY = 2 * 1024 * 1024
20
+
21
+
22
+ class Client:
23
+ """Client for the Rightra SDK API.
24
+
25
+ Args:
26
+ api_key: RIGHTRA_API_KEY. Must start with rk_live_ or rk_test_.
27
+ base_url: API base. Defaults to production.
28
+ timeout: Per-request timeout in seconds.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ api_key: str,
34
+ *,
35
+ base_url: str = _DEFAULT_BASE,
36
+ timeout: float = 30.0,
37
+ ) -> None:
38
+ if not isinstance(api_key, str) or not api_key.startswith(("rk_live_", "rk_test_")):
39
+ raise ValueError("api_key must start with rk_live_ or rk_test_.")
40
+ if not isinstance(base_url, str) or not base_url.startswith(("http://", "https://")):
41
+ raise ValueError("base_url must start with http:// or https://.")
42
+ if not isinstance(timeout, (int, float)) or timeout <= 0:
43
+ raise ValueError("timeout must be a positive number.")
44
+
45
+ self.api_key = api_key
46
+ self.base_url = base_url.rstrip("/")
47
+ self.timeout = float(timeout)
48
+
49
+ def _request(
50
+ self,
51
+ method: str,
52
+ path: str,
53
+ *,
54
+ body: Optional[Dict[str, Any]] = None,
55
+ stream_to: Optional[str] = None,
56
+ ) -> Dict[str, Any]:
57
+ url = f"{self.base_url}{path}"
58
+ data = None
59
+ if body is not None:
60
+ data = json.dumps(body).encode("utf-8")
61
+ if len(data) > _MAX_BODY:
62
+ raise ValueError("Request body exceeds the Rightra 2 MB limit.")
63
+
64
+ req = urlrequest.Request(url=url, data=data, method=method)
65
+ req.add_header("Authorization", f"Bearer {self.api_key}")
66
+ req.add_header("Accept", "application/json")
67
+ req.add_header("User-Agent", "rightra-python/0.1.0")
68
+ if data is not None:
69
+ req.add_header("Content-Type", "application/json")
70
+
71
+ try:
72
+ with urlrequest.urlopen(req, timeout=self.timeout) as resp:
73
+ if stream_to:
74
+ self._stream_to(resp, stream_to)
75
+ return {"success": True, "path": stream_to}
76
+ raw = resp.read()
77
+ return self._parse(raw, resp.status)
78
+ except urlerror.HTTPError as exc:
79
+ raw = b""
80
+ try:
81
+ raw = exc.read()
82
+ except Exception:
83
+ pass
84
+ parsed = self._safe_json(raw)
85
+ code = parsed.get("code") if isinstance(parsed, dict) else None
86
+ message = (
87
+ parsed.get("message")
88
+ if isinstance(parsed, dict) and isinstance(parsed.get("message"), str)
89
+ else "The Rightra request failed."
90
+ )
91
+ raise RightraError(message, status=exc.code, code=code, body=parsed) from None
92
+ except (urlerror.URLError, socket.timeout, TimeoutError) as exc:
93
+ reason = getattr(exc, "reason", exc)
94
+ raise RightraError(
95
+ "Could not reach the Rightra service.",
96
+ status=0,
97
+ code="NETWORK_ERROR",
98
+ body=str(reason),
99
+ ) from None
100
+
101
+ @staticmethod
102
+ def _parse(raw: bytes, status: int) -> Dict[str, Any]:
103
+ if not raw:
104
+ return {"success": True, "status": status}
105
+ try:
106
+ parsed = json.loads(raw.decode("utf-8"))
107
+ except (UnicodeDecodeError, json.JSONDecodeError):
108
+ raise RightraError(
109
+ "The Rightra service returned an invalid response.",
110
+ status=status,
111
+ code="INVALID_RESPONSE",
112
+ ) from None
113
+ if not isinstance(parsed, dict):
114
+ raise RightraError(
115
+ "The Rightra service returned an unexpected response.",
116
+ status=status,
117
+ code="INVALID_RESPONSE",
118
+ )
119
+ return parsed
120
+
121
+ @staticmethod
122
+ def _safe_json(raw: bytes) -> Any:
123
+ if not raw:
124
+ return None
125
+ try:
126
+ return json.loads(raw.decode("utf-8"))
127
+ except Exception:
128
+ return None
129
+
130
+ @staticmethod
131
+ def _stream_to(resp, dest_path: str) -> None:
132
+ with open(dest_path, "wb") as handle:
133
+ while True:
134
+ chunk = resp.read(64 * 1024)
135
+ if not chunk:
136
+ break
137
+ handle.write(chunk)
138
+
139
+ def register_content(
140
+ self,
141
+ *,
142
+ title: str,
143
+ type: str,
144
+ description: Optional[str] = None,
145
+ creator_name: Optional[str] = None,
146
+ rights_holder_name: Optional[str] = None,
147
+ storage_reference: Optional[Dict[str, Any]] = None,
148
+ encryption: Optional[Dict[str, Any]] = None,
149
+ ) -> Dict[str, Any]:
150
+ body: Dict[str, Any] = {"title": title, "type": type}
151
+ if description is not None:
152
+ body["description"] = description
153
+ if creator_name is not None:
154
+ body["creator_name"] = creator_name
155
+ if rights_holder_name is not None:
156
+ body["rights_holder_name"] = rights_holder_name
157
+ if storage_reference is not None:
158
+ body["storage_reference"] = storage_reference
159
+ if encryption is not None:
160
+ body["encryption"] = encryption
161
+ return self._request("POST", "/sdk/content", body=body)
162
+
163
+ def get_content(self, content_id: str) -> Dict[str, Any]:
164
+ self._require_content_id(content_id)
165
+ return self._request("GET", f"/sdk/content/{content_id}")
166
+
167
+ def set_encryption_key(
168
+ self,
169
+ content_id: str,
170
+ *,
171
+ key_material: str,
172
+ key_algorithm: str = "AES-256-GCM",
173
+ segment_manifest: Optional[Any] = None,
174
+ ) -> Dict[str, Any]:
175
+ self._require_content_id(content_id)
176
+ body: Dict[str, Any] = {
177
+ "key_material": key_material,
178
+ "key_algorithm": key_algorithm,
179
+ }
180
+ if segment_manifest is not None:
181
+ body["segment_manifest"] = segment_manifest
182
+ return self._request("POST", f"/sdk/content/{content_id}/key", body=body)
183
+
184
+ def set_manifest(
185
+ self,
186
+ content_id: str,
187
+ *,
188
+ manifest_url: str,
189
+ storage_details: Optional[Dict[str, Any]] = None,
190
+ ) -> Dict[str, Any]:
191
+ self._require_content_id(content_id)
192
+ body: Dict[str, Any] = {"manifest_url": manifest_url}
193
+ if storage_details is not None:
194
+ body["storage_reference"] = storage_details
195
+ return self._request("POST", f"/sdk/content/{content_id}/manifest", body=body)
196
+
197
+ def queue_fingerprint(
198
+ self,
199
+ content_id: str,
200
+ *,
201
+ reference_material: str,
202
+ reference_type: str = "audio_sample",
203
+ ) -> Dict[str, Any]:
204
+ self._require_content_id(content_id)
205
+ body = {
206
+ "reference_material": reference_material,
207
+ "reference_type": reference_type,
208
+ }
209
+ return self._request("POST", f"/sdk/content/{content_id}/fingerprint", body=body)
210
+
211
+ def create_playback_session(
212
+ self,
213
+ content_id: str,
214
+ *,
215
+ viewer_id: str,
216
+ viewer_label: Optional[str] = None,
217
+ territory: Optional[str] = None,
218
+ client_public_key: Optional[str] = None,
219
+ device: Optional[Dict[str, Any]] = None,
220
+ ) -> Dict[str, Any]:
221
+ self._require_content_id(content_id)
222
+ if not isinstance(viewer_id, str) or not viewer_id.strip():
223
+ raise ValueError("viewer_id is required. Rightra fails closed without it.")
224
+ viewer: Dict[str, Any] = {"id": viewer_id.strip()}
225
+ if viewer_label:
226
+ viewer["label"] = viewer_label
227
+ body: Dict[str, Any] = {"content_id": content_id, "viewer": viewer}
228
+ if territory:
229
+ body["requested_territory"] = territory.upper()[:2]
230
+ if client_public_key:
231
+ body["client_public_key"] = client_public_key
232
+ if device:
233
+ body["device"] = device
234
+ return self._request("POST", "/sdk/playback-session", body=body)
235
+
236
+ def playback_event(
237
+ self,
238
+ *,
239
+ session_id: str,
240
+ event: str,
241
+ seconds_watched: int = 0,
242
+ ) -> Dict[str, Any]:
243
+ if not session_id:
244
+ raise ValueError("session_id is required.")
245
+ body = {
246
+ "session_id": session_id,
247
+ "event": event,
248
+ "seconds_watched": int(seconds_watched),
249
+ }
250
+ return self._request("POST", "/sdk/playback-event", body=body)
251
+
252
+ def record_download_attempt(
253
+ self,
254
+ *,
255
+ session_id: str,
256
+ attempt_type: str = "unknown",
257
+ details: Optional[Dict[str, Any]] = None,
258
+ ) -> Dict[str, Any]:
259
+ if not session_id:
260
+ raise ValueError("session_id is required.")
261
+ body: Dict[str, Any] = {"session_id": session_id, "attempt_type": attempt_type}
262
+ if details is not None:
263
+ body["details"] = details
264
+ return self._request("POST", "/sdk/download-attempt", body=body)
265
+
266
+ def request_download(self, session_id: str) -> Dict[str, Any]:
267
+ if not session_id:
268
+ raise ValueError("session_id is required.")
269
+ return self._request("POST", "/sdk/download-request", body={"session_id": session_id})
270
+
271
+ def download_status(self, job_id: str) -> Dict[str, Any]:
272
+ if not job_id:
273
+ raise ValueError("job_id is required.")
274
+ return self._request("GET", f"/sdk/download-status/{job_id}")
275
+
276
+ def download_file(self, job_id: str, dest_path: str) -> Dict[str, Any]:
277
+ if not job_id:
278
+ raise ValueError("job_id is required.")
279
+ if not dest_path:
280
+ raise ValueError("dest_path is required.")
281
+ return self._request("GET", f"/sdk/download-file/{job_id}", stream_to=dest_path)
282
+
283
+ @staticmethod
284
+ def _require_content_id(content_id: str) -> None:
285
+ if not isinstance(content_id, str) or not _CONTENT_ID_RE.match(content_id):
286
+ raise ValueError("content_id must look like RTR-XXXX-XXXX (uppercase hex).")
rightra/errors.py ADDED
@@ -0,0 +1,33 @@
1
+ """Exception types for the Rightra Python package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+
8
+ class RightraError(Exception):
9
+ """Raised for every Rightra failure.
10
+
11
+ Attributes:
12
+ status: HTTP status code. 0 for network or transport failures.
13
+ code: Rightra error code when the server provided one, else None.
14
+ message: A sentence safe to show a user.
15
+ body: Parsed response body when available, else None.
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ message: str,
21
+ *,
22
+ status: int = 0,
23
+ code: Optional[str] = None,
24
+ body: Optional[Any] = None,
25
+ ) -> None:
26
+ super().__init__(message)
27
+ self.status = status
28
+ self.code = code
29
+ self.message = message
30
+ self.body = body
31
+
32
+ def __repr__(self) -> str:
33
+ return f"RightraError(status={self.status!r}, code={self.code!r}, message={self.message!r})"
rightra/py.typed ADDED
File without changes
rightra/storage.py ADDED
@@ -0,0 +1,166 @@
1
+ """Presigned PUT URL helpers for S3 and Cloudflare R2.
2
+
3
+ Pure Python. No boto3, no botocore, no aws-sdk. Signs with SigV4 using
4
+ hmac and hashlib from the standard library.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import hmac
11
+ from datetime import datetime, timezone
12
+ from typing import Dict, Optional
13
+ from urllib.parse import quote, urlencode
14
+
15
+ _ALGO = "AWS4-HMAC-SHA256"
16
+ _SERVICE = "s3"
17
+ _MAX_EXPIRES = 604800
18
+
19
+
20
+ def _hmac(key: bytes, msg: str) -> bytes:
21
+ return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
22
+
23
+
24
+ def _sha256_hex(data: bytes) -> str:
25
+ return hashlib.sha256(data).hexdigest()
26
+
27
+
28
+ def _validate_expires(expires: int) -> None:
29
+ if not isinstance(expires, int) or isinstance(expires, bool):
30
+ raise ValueError("expires must be an integer number of seconds.")
31
+ if expires <= 0 or expires > _MAX_EXPIRES:
32
+ raise ValueError(f"expires must be between 1 and {_MAX_EXPIRES} seconds.")
33
+
34
+
35
+ def _encode_key(key: str) -> str:
36
+ return "/".join(quote(segment, safe="-_.~") for segment in key.split("/"))
37
+
38
+
39
+ def _signing_key(secret_key: str, date_stamp: str, region: str) -> bytes:
40
+ k_date = _hmac(("AWS4" + secret_key).encode("utf-8"), date_stamp)
41
+ k_region = _hmac(k_date, region)
42
+ k_service = _hmac(k_region, _SERVICE)
43
+ return _hmac(k_service, "aws4_request")
44
+
45
+
46
+ def _build_presigned_url(
47
+ *,
48
+ scheme: str,
49
+ host: str,
50
+ bucket: str,
51
+ key: str,
52
+ access_key: str,
53
+ secret_key: str,
54
+ region: str,
55
+ expires: int,
56
+ content_type: Optional[str],
57
+ now: Optional[datetime],
58
+ ) -> str:
59
+ _validate_expires(expires)
60
+ if not access_key or not secret_key:
61
+ raise ValueError("access_key and secret_key are required.")
62
+ if not bucket or not key:
63
+ raise ValueError("bucket and key are required.")
64
+
65
+ moment = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
66
+ amz_date = moment.strftime("%Y%m%dT%H%M%SZ")
67
+ date_stamp = moment.strftime("%Y%m%d")
68
+
69
+ canonical_uri = "/" + _encode_key(key)
70
+ canonical_headers = f"host:{host}\n"
71
+ signed_headers = "host"
72
+
73
+ query: Dict[str, str] = {
74
+ "X-Amz-Algorithm": _ALGO,
75
+ "X-Amz-Credential": f"{access_key}/{date_stamp}/{region}/{_SERVICE}/aws4_request",
76
+ "X-Amz-Date": amz_date,
77
+ "X-Amz-Expires": str(expires),
78
+ "X-Amz-SignedHeaders": signed_headers,
79
+ }
80
+ if content_type:
81
+ query["response-content-type"] = content_type
82
+
83
+ canonical_query = urlencode(sorted(query.items()), quote_via=quote, safe="-_.~")
84
+
85
+ canonical_request = "\n".join(
86
+ [
87
+ "PUT",
88
+ canonical_uri,
89
+ canonical_query,
90
+ canonical_headers,
91
+ signed_headers,
92
+ "UNSIGNED-PAYLOAD",
93
+ ]
94
+ )
95
+
96
+ credential_scope = f"{date_stamp}/{region}/{_SERVICE}/aws4_request"
97
+ string_to_sign = "\n".join(
98
+ [
99
+ _ALGO,
100
+ amz_date,
101
+ credential_scope,
102
+ _sha256_hex(canonical_request.encode("utf-8")),
103
+ ]
104
+ )
105
+
106
+ key_b = _signing_key(secret_key, date_stamp, region)
107
+ signature = hmac.new(key_b, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
108
+
109
+ final_query = canonical_query + "&X-Amz-Signature=" + signature
110
+ return f"{scheme}://{host}{canonical_uri}?{final_query}"
111
+
112
+
113
+ def sign_s3_put(
114
+ *,
115
+ access_key: str,
116
+ secret_key: str,
117
+ bucket: str,
118
+ key: str,
119
+ region: str = "us-east-1",
120
+ expires: int = 900,
121
+ content_type: Optional[str] = None,
122
+ now: Optional[datetime] = None,
123
+ ) -> str:
124
+ """Return a presigned S3 PUT URL."""
125
+ host = f"{bucket}.s3.{region}.amazonaws.com"
126
+ return _build_presigned_url(
127
+ scheme="https",
128
+ host=host,
129
+ bucket=bucket,
130
+ key=key,
131
+ access_key=access_key,
132
+ secret_key=secret_key,
133
+ region=region,
134
+ expires=expires,
135
+ content_type=content_type,
136
+ now=now,
137
+ )
138
+
139
+
140
+ def sign_r2_put(
141
+ *,
142
+ account_id: str,
143
+ access_key: str,
144
+ secret_key: str,
145
+ bucket: str,
146
+ key: str,
147
+ expires: int = 900,
148
+ content_type: Optional[str] = None,
149
+ now: Optional[datetime] = None,
150
+ ) -> str:
151
+ """Return a presigned Cloudflare R2 PUT URL."""
152
+ if not account_id:
153
+ raise ValueError("account_id is required for R2.")
154
+ host = f"{account_id}.r2.cloudflarestorage.com"
155
+ return _build_presigned_url(
156
+ scheme="https",
157
+ host=host,
158
+ bucket=bucket,
159
+ key=f"{bucket}/{key}",
160
+ access_key=access_key,
161
+ secret_key=secret_key,
162
+ region="auto",
163
+ expires=expires,
164
+ content_type=content_type,
165
+ now=now,
166
+ )
rightra/upload.py ADDED
@@ -0,0 +1,108 @@
1
+ """Upload token helpers.
2
+
3
+ The token format matches the PHP helper (``rightra-upload.php``) and the
4
+ Laravel package (``UploadToken::mint``):
5
+
6
+ X-Rightra-Token: <expiry>.<hmac>
7
+ hmac = HMAC-SHA256("<contentId>|<expiry>", RIGHTRA_UPLOAD_SECRET)
8
+
9
+ ``expiry`` is a unix timestamp. The token is bound to one content id and is
10
+ valid until that timestamp. The master secret never reaches the browser.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import hmac
17
+ import re
18
+ import time
19
+ from typing import Optional
20
+
21
+ _CONTENT_ID_RE = re.compile(r"^RTR-[A-F0-9]{4}-[A-F0-9]{4}$")
22
+ _TOKEN_RE = re.compile(r"^(\d{9,11})\.([a-f0-9]{64})$")
23
+
24
+ DEFAULT_TTL = 900
25
+ _MAX_TTL = 86400 # 24 hours. Anything longer is a bug, not a feature.
26
+
27
+
28
+ def _require_content_id(content_id: str) -> str:
29
+ if not isinstance(content_id, str) or not _CONTENT_ID_RE.match(content_id):
30
+ raise ValueError("content_id must look like RTR-XXXX-XXXX (uppercase hex).")
31
+ return content_id
32
+
33
+
34
+ def _require_secret(secret: str) -> bytes:
35
+ if not isinstance(secret, str) or not secret:
36
+ raise ValueError("secret is required to mint or verify upload tokens.")
37
+ return secret.encode("utf-8")
38
+
39
+
40
+ def _sign(content_id: str, expiry: int, secret: bytes) -> str:
41
+ payload = f"{content_id}|{expiry}".encode("utf-8")
42
+ return hmac.new(secret, payload, hashlib.sha256).hexdigest()
43
+
44
+
45
+ def mint_upload_token(
46
+ content_id: str,
47
+ secret: str,
48
+ *,
49
+ ttl: int = DEFAULT_TTL,
50
+ now: Optional[int] = None,
51
+ ) -> str:
52
+ """Return ``"<expiry>.<hmac>"`` for the given content id.
53
+
54
+ Args:
55
+ content_id: A Rightra content id, ``RTR-XXXX-XXXX``.
56
+ secret: ``RIGHTRA_UPLOAD_SECRET``.
57
+ ttl: Seconds the token stays valid. Defaults to 900.
58
+ now: Override the current unix time. Useful in tests.
59
+
60
+ Raises:
61
+ ValueError: If inputs are invalid.
62
+ """
63
+ _require_content_id(content_id)
64
+ secret_b = _require_secret(secret)
65
+
66
+ if not isinstance(ttl, int) or isinstance(ttl, bool):
67
+ raise ValueError("ttl must be an integer.")
68
+ if ttl <= 0 or ttl > _MAX_TTL:
69
+ raise ValueError(f"ttl must be between 1 and {_MAX_TTL} seconds.")
70
+
71
+ current = int(now if now is not None else time.time())
72
+ expiry = current + ttl
73
+ signature = _sign(content_id, expiry, secret_b)
74
+ return f"{expiry}.{signature}"
75
+
76
+
77
+ def verify_upload_token(
78
+ token: str,
79
+ content_id: str,
80
+ secret: str,
81
+ *,
82
+ now: Optional[int] = None,
83
+ ) -> bool:
84
+ """Return ``True`` if the token is well formed, unexpired, and signed correctly.
85
+
86
+ Uses a constant-time comparison so timing does not leak the expected signature.
87
+ """
88
+ if not isinstance(token, str):
89
+ return False
90
+ try:
91
+ _require_content_id(content_id)
92
+ secret_b = _require_secret(secret)
93
+ except ValueError:
94
+ return False
95
+
96
+ match = _TOKEN_RE.match(token)
97
+ if not match:
98
+ return False
99
+
100
+ expiry = int(match.group(1))
101
+ provided = match.group(2)
102
+
103
+ current = int(now if now is not None else time.time())
104
+ if expiry < current:
105
+ return False
106
+
107
+ expected = _sign(content_id, expiry, secret_b)
108
+ return hmac.compare_digest(expected, provided)
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: rightra
3
+ Version: 0.1.0
4
+ Summary: Server-side helpers for Rightra: upload tokens, presigned storage URLs, and the Rightra API client for Django, Flask, and any Python backend.
5
+ Author-email: OLIVIUUS Ltd <contact@oliviuus.com>
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Homepage, https://docs.oliviuus.com/rightra/python
8
+ Project-URL: Source, https://github.com/hagenimanasamuel/rightra-python
9
+ Project-URL: Repository, https://github.com/hagenimanasamuel/rightra-python
10
+ Keywords: rightra,oliviuus,drm,watermark,video,django,flask
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Framework :: Django
18
+ Classifier: Framework :: Flask
19
+ Classifier: Operating System :: OS Independent
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # Rightra for Python
26
+
27
+ Server-side helpers for Rightra, the digital content protection and rights intelligence product by Oliviuus.
28
+
29
+ This package does the backend side only: mint short-lived upload tokens, sign S3 and R2 uploads, and call the Rightra API from a Django, Flask, or any Python backend. The browser player is loaded from a script tag on the page. It is not part of this package.
30
+
31
+ Contact: contact@oliviuus.com
32
+
33
+ ## Requirements
34
+
35
+ - Python 3.9 or newer
36
+ - No runtime dependencies
37
+
38
+ ## Install
39
+
40
+ pip install rightra
41
+
42
+ ## Configure
43
+
44
+ Store these values as environment variables or in your framework settings:
45
+
46
+ RIGHTRA_API_KEY=rk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
47
+ RIGHTRA_UPLOAD_SECRET=your-upload-secret
48
+ RIGHTRA_UPLOAD_TOKEN_TTL=900
49
+
50
+ RIGHTRA_API_KEY is required. Without it every API call fails closed.
51
+
52
+ ## Upload tokens
53
+
54
+ The PHP upload helper and the Laravel UploadToken::mint both expect a token in the form <expiry>.<hmac>, where hmac = HMAC-SHA256("<contentId>|<expiry>", RIGHTRA_UPLOAD_SECRET) and expiry is a unix timestamp. The Python helper produces exactly the same string.
55
+
56
+ import os
57
+ import rightra
58
+
59
+ token = rightra.mint_upload_token("RTR-853D-52D0", secret=os.environ["RIGHTRA_UPLOAD_SECRET"])
60
+
61
+ Send the token to your frontend and let the browser send it as the X-Rightra-Token header when it calls rightra-upload.php. The master secret never reaches the browser.
62
+
63
+ rightra.verify_upload_token(token, content_id, secret) performs the same check the PHP helper does. Use it if you proxy uploads through your own Python backend instead of the PHP helper.
64
+
65
+ ## API client
66
+
67
+ import os
68
+ from rightra import Client
69
+
70
+ client = Client(api_key=os.environ["RIGHTRA_API_KEY"])
71
+
72
+ created = client.register_content(
73
+ title="My Film",
74
+ type="film",
75
+ description="Feature length film.",
76
+ creator_name="Example Studio",
77
+ )
78
+
79
+ content_id = created["content"]["content_id"]
80
+
81
+ client.set_manifest(
82
+ content_id,
83
+ manifest_url="https://your-storage.example/manifest.json",
84
+ )
85
+
86
+ session = client.create_playback_session(
87
+ content_id,
88
+ viewer_id=str(request.user.id),
89
+ viewer_label=request.user.get_full_name(),
90
+ territory="RW",
91
+ )
92
+
93
+ ## Presigned uploads
94
+
95
+ Sign PUT URLs for S3 or R2 without pulling in boto3.
96
+
97
+ import os
98
+ from rightra import sign_s3_put, sign_r2_put
99
+
100
+ put_url = sign_s3_put(
101
+ access_key=os.environ["AWS_ACCESS_KEY_ID"],
102
+ secret_key=os.environ["AWS_SECRET_ACCESS_KEY"],
103
+ bucket="my-rightra-bucket",
104
+ key=f"uploads/{content_id}/seg-0.enc",
105
+ region="us-east-1",
106
+ )
107
+
108
+ r2_url = sign_r2_put(
109
+ account_id=os.environ["R2_ACCOUNT_ID"],
110
+ access_key=os.environ["R2_ACCESS_KEY_ID"],
111
+ secret_key=os.environ["R2_SECRET_ACCESS_KEY"],
112
+ bucket="rightra",
113
+ key=f"uploads/{content_id}/seg-0.enc",
114
+ )
115
+
116
+ ## Errors
117
+
118
+ Every failure raises rightra.RightraError. The exception carries .status, .code, and .message. Network errors are wrapped in the same type with .status == 0.
119
+
120
+ ## Framework notes
121
+
122
+ Django and Flask users import the same module. There is no app context and no settings module lookup. Pass configuration explicitly from your own settings layer so secrets stay in one place.
123
+
124
+ ## Limits
125
+
126
+ This package does not encrypt or decrypt media. It does not render a player. Those are the browser SDK's job. The Python package is the server side of the same workflow.
127
+
128
+ ## License
129
+
130
+ Proprietary. Copyright OLIVIUUS Ltd. Contact: contact@oliviuus.com
@@ -0,0 +1,11 @@
1
+ rightra/__init__.py,sha256=EWC_uF7Veq1qPCovELLkukpY6raDiWgTnY03fkYZNKE,813
2
+ rightra/client.py,sha256=speqVu55OZlRYR_sxqxe1HAmneEj4Rzgxyv_muwIJnY,10607
3
+ rightra/errors.py,sha256=FJKbpeQXampmpWmbXZyZcvB9z-_0ydPjFZtXTQtWwQE,965
4
+ rightra/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ rightra/storage.py,sha256=tiK7t8OFSd0nTYHYTN_I_vO5ixhOZEcN93ROzAikvgc,4777
6
+ rightra/upload.py,sha256=ABQ3JFfAliQue24Oyo-5vhC9cJXEkbQdIlDQa2iw3LQ,3284
7
+ rightra-0.1.0.dist-info/licenses/LICENSE,sha256=edfpeFBqVonXQAC2-Iz8S-yiCSNlS6d2QemRzCw8LcI,796
8
+ rightra-0.1.0.dist-info/METADATA,sha256=iQOE-Mz33Qc-lG_KS6PxAB2CFcl-0cgL7at9IJp1FrU,4776
9
+ rightra-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ rightra-0.1.0.dist-info/top_level.txt,sha256=bBFhs6j0mpTp40ZuNCyUr4MecTvH2nbSdekzbzag6ds,8
11
+ rightra-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,16 @@
1
+ Copyright (c) OLIVIUUS Ltd. All rights reserved.
2
+
3
+ This software and its source code are proprietary and confidential.
4
+
5
+ No permission is granted to use, copy, modify, merge, publish, distribute,
6
+ sublicense, or sell copies of this software, in whole or in part, without
7
+ prior written consent from OLIVIUUS Ltd.
8
+
9
+ Contact: contact@oliviuus.com
10
+
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
14
+ OLIVIUUS LTD BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
15
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
16
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ rightra