tasalsul 0.2.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.
- tasalsul/__init__.py +63 -0
- tasalsul/cli.py +212 -0
- tasalsul/engine.py +453 -0
- tasalsul/format.py +189 -0
- tasalsul-0.2.0.dist-info/METADATA +244 -0
- tasalsul-0.2.0.dist-info/RECORD +10 -0
- tasalsul-0.2.0.dist-info/WHEEL +5 -0
- tasalsul-0.2.0.dist-info/entry_points.txt +2 -0
- tasalsul-0.2.0.dist-info/licenses/LICENSE +21 -0
- tasalsul-0.2.0.dist-info/top_level.txt +1 -0
tasalsul/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
تسلسل (Tasalsul) — أرشيف مضغوط ومشفّر للمسلسلات.
|
|
3
|
+
صيغة .tslsl v2: AES-256-GCM + zlib + ميتاداتا موسّعة.
|
|
4
|
+
|
|
5
|
+
Quick start
|
|
6
|
+
-----------
|
|
7
|
+
>>> from tasalsul import create_archive, extract_archive, read_metadata
|
|
8
|
+
>>> result = create_archive(
|
|
9
|
+
... source="episode.mp4",
|
|
10
|
+
... dest="episode.tslsl",
|
|
11
|
+
... password="سري",
|
|
12
|
+
... series_name="Breaking Bad",
|
|
13
|
+
... season=1, episode=1,
|
|
14
|
+
... )
|
|
15
|
+
>>> meta = read_metadata("episode.tslsl")
|
|
16
|
+
>>> print(meta.series_name)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .engine import (
|
|
20
|
+
# functions
|
|
21
|
+
create_archive,
|
|
22
|
+
extract_archive,
|
|
23
|
+
read_metadata,
|
|
24
|
+
migrate_v1,
|
|
25
|
+
# result types
|
|
26
|
+
ArchiveResult,
|
|
27
|
+
ExtractResult,
|
|
28
|
+
# metadata models
|
|
29
|
+
SeriesMetadata,
|
|
30
|
+
SubtitleRef,
|
|
31
|
+
ThumbnailMeta,
|
|
32
|
+
# errors
|
|
33
|
+
TasalsulError,
|
|
34
|
+
InvalidPasswordError,
|
|
35
|
+
CorruptArchiveError,
|
|
36
|
+
UnsupportedVersionError,
|
|
37
|
+
# constants
|
|
38
|
+
FORMAT_VERSION,
|
|
39
|
+
MAGIC,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__version__ = "0.2.0"
|
|
43
|
+
__author__ = "Tasalsul Contributors"
|
|
44
|
+
__license__ = "MIT"
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"create_archive",
|
|
48
|
+
"extract_archive",
|
|
49
|
+
"read_metadata",
|
|
50
|
+
"migrate_v1",
|
|
51
|
+
"ArchiveResult",
|
|
52
|
+
"ExtractResult",
|
|
53
|
+
"SeriesMetadata",
|
|
54
|
+
"SubtitleRef",
|
|
55
|
+
"ThumbnailMeta",
|
|
56
|
+
"TasalsulError",
|
|
57
|
+
"InvalidPasswordError",
|
|
58
|
+
"CorruptArchiveError",
|
|
59
|
+
"UnsupportedVersionError",
|
|
60
|
+
"FORMAT_VERSION",
|
|
61
|
+
"MAGIC",
|
|
62
|
+
"__version__",
|
|
63
|
+
]
|
tasalsul/cli.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""واجهة سطر الأوامر لتسلسل v2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import getpass
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .engine import (
|
|
13
|
+
create_archive,
|
|
14
|
+
extract_archive,
|
|
15
|
+
read_metadata,
|
|
16
|
+
migrate_v1,
|
|
17
|
+
InvalidPasswordError,
|
|
18
|
+
CorruptArchiveError,
|
|
19
|
+
UnsupportedVersionError,
|
|
20
|
+
ThumbnailMeta,
|
|
21
|
+
SubtitleRef,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_password(arg_password: str | None, confirm: bool = False) -> str:
|
|
26
|
+
if arg_password:
|
|
27
|
+
return arg_password
|
|
28
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
29
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
30
|
+
pw = getpass.getpass("🔑 كلمة السر: ")
|
|
31
|
+
if confirm:
|
|
32
|
+
pw2 = getpass.getpass("🔑 تأكيد كلمة السر: ")
|
|
33
|
+
if pw != pw2:
|
|
34
|
+
print("❌ كلمتا السر غير متطابقتين.", file=sys.stderr)
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
return pw
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_create(args: argparse.Namespace) -> None:
|
|
40
|
+
password = _get_password(args.password, confirm=True)
|
|
41
|
+
output = args.output or Path(args.input).with_suffix(".tslsl")
|
|
42
|
+
|
|
43
|
+
thumbnail = None
|
|
44
|
+
if args.thumbnail:
|
|
45
|
+
import base64
|
|
46
|
+
data = Path(args.thumbnail).read_bytes()
|
|
47
|
+
thumbnail = ThumbnailMeta(
|
|
48
|
+
data_b64=base64.b64encode(data).decode(),
|
|
49
|
+
mime="image/jpeg" if args.thumbnail.endswith((".jpg", ".jpeg")) else "image/png",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
subtitles = []
|
|
53
|
+
if args.subtitle:
|
|
54
|
+
import base64
|
|
55
|
+
for sub in args.subtitle:
|
|
56
|
+
lang, path = sub.split(":", 1)
|
|
57
|
+
data = Path(path).read_bytes()
|
|
58
|
+
subtitles.append(SubtitleRef(
|
|
59
|
+
language=lang,
|
|
60
|
+
label=lang,
|
|
61
|
+
data_b64=base64.b64encode(data).decode(),
|
|
62
|
+
))
|
|
63
|
+
|
|
64
|
+
result = create_archive(
|
|
65
|
+
source = args.input,
|
|
66
|
+
dest = output,
|
|
67
|
+
password = password,
|
|
68
|
+
series_name = args.series,
|
|
69
|
+
season = args.season,
|
|
70
|
+
episode = args.episode,
|
|
71
|
+
title = args.title,
|
|
72
|
+
description = args.description,
|
|
73
|
+
duration = args.duration,
|
|
74
|
+
resolution = args.resolution,
|
|
75
|
+
language = args.language,
|
|
76
|
+
subtitles = subtitles or None,
|
|
77
|
+
thumbnail = thumbnail,
|
|
78
|
+
tags = args.tag or [],
|
|
79
|
+
source_url = args.source_url,
|
|
80
|
+
content_rating= args.rating,
|
|
81
|
+
)
|
|
82
|
+
mb = result.size_bytes / 1_048_576
|
|
83
|
+
print(f"✅ تم: {output} ({mb:.2f} MB)")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_extract(args: argparse.Namespace) -> None:
|
|
87
|
+
password = _get_password(args.password)
|
|
88
|
+
output = args.output
|
|
89
|
+
if not output:
|
|
90
|
+
meta = read_metadata(args.input)
|
|
91
|
+
output = Path(args.input).parent / meta.original_filename
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
result = extract_archive(source=args.input, dest=output, password=password)
|
|
95
|
+
except InvalidPasswordError as exc:
|
|
96
|
+
print(f"❌ {exc}", file=sys.stderr); sys.exit(2)
|
|
97
|
+
except CorruptArchiveError as exc:
|
|
98
|
+
print(f"❌ {exc}", file=sys.stderr); sys.exit(3)
|
|
99
|
+
print(f"✅ استُخرج إلى: {result.path}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cmd_info(args: argparse.Namespace) -> None:
|
|
103
|
+
try:
|
|
104
|
+
meta = read_metadata(args.input)
|
|
105
|
+
except (CorruptArchiveError, UnsupportedVersionError) as exc:
|
|
106
|
+
print(f"❌ {exc}", file=sys.stderr); sys.exit(3)
|
|
107
|
+
|
|
108
|
+
if args.json:
|
|
109
|
+
print(meta.to_json(indent=2))
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
def row(label, value):
|
|
113
|
+
if value is not None and value != "" and value != []:
|
|
114
|
+
print(f" {label:<18}: {value}")
|
|
115
|
+
|
|
116
|
+
print(f"\n📦 تسلسل — v{meta.format_version}\n{'─'*40}")
|
|
117
|
+
row("المسلسل", meta.series_name)
|
|
118
|
+
row("الموسم", meta.season)
|
|
119
|
+
row("الحلقة", meta.episode)
|
|
120
|
+
row("العنوان", meta.title)
|
|
121
|
+
row("الوصف", meta.description)
|
|
122
|
+
row("المدة", f"{meta.duration_seconds:.0f}s" if meta.duration_seconds else None)
|
|
123
|
+
row("الدقة", meta.resolution)
|
|
124
|
+
row("اللغة", meta.language)
|
|
125
|
+
row("التصنيف", meta.content_rating)
|
|
126
|
+
row("الملف", meta.original_filename)
|
|
127
|
+
row("الإنشاء", meta.created_at[:19])
|
|
128
|
+
row("الضغط", meta.compression)
|
|
129
|
+
row("التشفير", meta.encryption)
|
|
130
|
+
if meta.tags:
|
|
131
|
+
row("الوسوم", ", ".join(meta.tags))
|
|
132
|
+
if meta.subtitles:
|
|
133
|
+
langs = ", ".join(s.language for s in meta.subtitles)
|
|
134
|
+
row("الترجمات", langs)
|
|
135
|
+
if meta.thumbnail:
|
|
136
|
+
row("مصغّرة", f"{meta.thumbnail.width}×{meta.thumbnail.height} {meta.thumbnail.mime}")
|
|
137
|
+
print()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_migrate(args: argparse.Namespace) -> None:
|
|
141
|
+
password = _get_password(args.password)
|
|
142
|
+
output = args.output or Path(args.input).with_suffix(".v2.tslsl")
|
|
143
|
+
try:
|
|
144
|
+
result = migrate_v1(source=args.input, dest=output, password=password)
|
|
145
|
+
except InvalidPasswordError as exc:
|
|
146
|
+
print(f"❌ {exc}", file=sys.stderr); sys.exit(2)
|
|
147
|
+
print(f"✅ تم الترقية: {output}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
151
|
+
parser = argparse.ArgumentParser(
|
|
152
|
+
prog="tslsl",
|
|
153
|
+
description="تسلسل: أرشيف مضغوط ومشفّر للمسلسلات (.tslsl)",
|
|
154
|
+
)
|
|
155
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
156
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
157
|
+
|
|
158
|
+
# ── create ─────────────────────────────────────────────────────────
|
|
159
|
+
c = sub.add_parser("create", help="إنشاء أرشيف .tslsl")
|
|
160
|
+
c.add_argument("input", help="ملف الفيديو المصدر")
|
|
161
|
+
c.add_argument("-o", "--output", help="مسار الأرشيف الناتج")
|
|
162
|
+
c.add_argument("-s", "--series", required=True, help="اسم المسلسل")
|
|
163
|
+
c.add_argument("--season", type=int, help="رقم الموسم")
|
|
164
|
+
c.add_argument("--episode", type=int, help="رقم الحلقة")
|
|
165
|
+
c.add_argument("--title", help="عنوان الحلقة")
|
|
166
|
+
c.add_argument("--description", help="وصف الحلقة")
|
|
167
|
+
c.add_argument("--duration", type=float, help="المدة بالثواني")
|
|
168
|
+
c.add_argument("--resolution", help="الدقة مثل 1920x1080")
|
|
169
|
+
c.add_argument("--language", help="لغة الحلقة (ar, en, ...)")
|
|
170
|
+
c.add_argument("--rating", help="التصنيف العمري")
|
|
171
|
+
c.add_argument("--source-url", help="رابط المصدر")
|
|
172
|
+
c.add_argument("--tag", action="append", help="وسم (يمكن تكراره)")
|
|
173
|
+
c.add_argument("--thumbnail", help="مسار صورة مصغّرة (jpg/png)")
|
|
174
|
+
c.add_argument("--subtitle", action="append",
|
|
175
|
+
metavar="LANG:FILE", help="ترجمة: ar:subs_ar.srt")
|
|
176
|
+
c.add_argument("-p", "--password", help="كلمة السر")
|
|
177
|
+
c.set_defaults(func=cmd_create)
|
|
178
|
+
|
|
179
|
+
# ── extract ────────────────────────────────────────────────────────
|
|
180
|
+
e = sub.add_parser("extract", help="استخراج فيديو من أرشيف")
|
|
181
|
+
e.add_argument("input")
|
|
182
|
+
e.add_argument("-o", "--output")
|
|
183
|
+
e.add_argument("-p", "--password")
|
|
184
|
+
e.set_defaults(func=cmd_extract)
|
|
185
|
+
|
|
186
|
+
# ── info ───────────────────────────────────────────────────────────
|
|
187
|
+
i = sub.add_parser("info", help="عرض ميتاداتا الأرشيف")
|
|
188
|
+
i.add_argument("input")
|
|
189
|
+
i.add_argument("--json", action="store_true", help="إخراج بصيغة JSON")
|
|
190
|
+
i.set_defaults(func=cmd_info)
|
|
191
|
+
|
|
192
|
+
# ── migrate ────────────────────────────────────────────────────────
|
|
193
|
+
m = sub.add_parser("migrate", help="ترقية أرشيف v1 إلى v2")
|
|
194
|
+
m.add_argument("input")
|
|
195
|
+
m.add_argument("-o", "--output")
|
|
196
|
+
m.add_argument("-p", "--password")
|
|
197
|
+
m.set_defaults(func=cmd_migrate)
|
|
198
|
+
|
|
199
|
+
return parser
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def main(argv: list[str] | None = None) -> None:
|
|
203
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
204
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
205
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
206
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
207
|
+
args = build_parser().parse_args(argv)
|
|
208
|
+
args.func(args)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
main()
|
tasalsul/engine.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""
|
|
2
|
+
تسلسل (TSLSL) — Archive Engine v2
|
|
3
|
+
===================================
|
|
4
|
+
صيغة أرشيف مضغوط ومشفّر من الجيل الثاني لفيديوهات المسلسلات.
|
|
5
|
+
|
|
6
|
+
الجديد في v2:
|
|
7
|
+
• بث (streaming) الإنشاء والاستخراج — لا حاجة لتحميل الملف كاملاً
|
|
8
|
+
• دعم ملفات الويب (bytes / BytesIO / BinaryIO)
|
|
9
|
+
• ميتاداتا موسّعة: صورة مصغّرة، مدة، دقة، لغة، ترجمات
|
|
10
|
+
• فحص سلامة تدريجي (chunked SHA-256)
|
|
11
|
+
• نسخة الصيغة مُضمَّنة للتوافق الأمامي
|
|
12
|
+
• استدعاء نظيف من JavaScript عبر Pyodide / WASM
|
|
13
|
+
|
|
14
|
+
بنية الملف (.tslsl v2):
|
|
15
|
+
┌──────────────┬─────────────────────────────────────────┐
|
|
16
|
+
│ 8 bytes │ MAGIC = b'TSLSL002' │
|
|
17
|
+
│ 2 bytes │ FORMAT_VERSION (uint16 big-endian) │
|
|
18
|
+
│ 4 bytes │ HEADER_LEN (uint32 big-endian) │
|
|
19
|
+
│ N bytes │ HEADER (JSON/UTF-8, unencrypted) │
|
|
20
|
+
│ 16 bytes │ SALT (random, for key derivation) │
|
|
21
|
+
│ 16 bytes │ IV (AES-GCM nonce) │
|
|
22
|
+
│ M bytes │ PAYLOAD (zstd → AES-256-GCM) │
|
|
23
|
+
│ 16 bytes │ GCM AUTH TAG │
|
|
24
|
+
└──────────────┴─────────────────────────────────────────┘
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import base64
|
|
30
|
+
import hashlib
|
|
31
|
+
import io
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import struct
|
|
35
|
+
import zlib
|
|
36
|
+
from dataclasses import dataclass, field, asdict
|
|
37
|
+
from datetime import datetime, timezone
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import BinaryIO, Iterator, Optional, Union
|
|
40
|
+
|
|
41
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
42
|
+
from cryptography.hazmat.primitives import hashes
|
|
43
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
44
|
+
|
|
45
|
+
# ───────────────────────────── constants ──────────────────────────────
|
|
46
|
+
MAGIC = b"TSLSL002"
|
|
47
|
+
FORMAT_VERSION = 2
|
|
48
|
+
SALT_SIZE = 16
|
|
49
|
+
IV_SIZE = 16 # AES-GCM nonce
|
|
50
|
+
KEY_SIZE = 32 # AES-256
|
|
51
|
+
PBKDF2_ITER = 390_000
|
|
52
|
+
CHUNK_SIZE = 256 * 1024 # 256 KB streaming chunks
|
|
53
|
+
TAG_SIZE = 16 # GCM auth tag
|
|
54
|
+
|
|
55
|
+
FileInput = Union[str, Path, bytes, BinaryIO]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ───────────────────────────── errors ─────────────────────────────────
|
|
59
|
+
class TasalsulError(Exception):
|
|
60
|
+
"""خطأ عام في معالجة أرشيف تسلسل."""
|
|
61
|
+
|
|
62
|
+
class InvalidPasswordError(TasalsulError):
|
|
63
|
+
"""كلمة السر خاطئة أو البيانات تالفة."""
|
|
64
|
+
|
|
65
|
+
class CorruptArchiveError(TasalsulError):
|
|
66
|
+
"""الأرشيف تالف أو لا يطابق صيغة تسلسل."""
|
|
67
|
+
|
|
68
|
+
class UnsupportedVersionError(TasalsulError):
|
|
69
|
+
"""إصدار الصيغة غير مدعوم."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ───────────────────────────── metadata ───────────────────────────────
|
|
73
|
+
@dataclass
|
|
74
|
+
class ThumbnailMeta:
|
|
75
|
+
"""صورة مصغّرة مُضمَّنة في الميتاداتا (base64)."""
|
|
76
|
+
data_b64: str = "" # base64 للصورة
|
|
77
|
+
mime: str = "image/jpeg"
|
|
78
|
+
width: int = 0
|
|
79
|
+
height: int = 0
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class SubtitleRef:
|
|
83
|
+
"""مرجع لملف ترجمة خارجي أو مُضمَّن."""
|
|
84
|
+
language: str # "ar", "en", …
|
|
85
|
+
label: str # "العربية", "English", …
|
|
86
|
+
url: str = "" # رابط خارجي (اختياري)
|
|
87
|
+
data_b64: str = "" # بيانات .srt مُضمَّنة (اختياري)
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class SeriesMetadata:
|
|
91
|
+
"""ميتاداتا الحلقة — مرئية بلا كلمة سر."""
|
|
92
|
+
|
|
93
|
+
# أساسية
|
|
94
|
+
series_name: str
|
|
95
|
+
original_filename: str
|
|
96
|
+
season: Optional[int] = None
|
|
97
|
+
episode: Optional[int] = None
|
|
98
|
+
title: Optional[str] = None
|
|
99
|
+
|
|
100
|
+
# جديدة في v2
|
|
101
|
+
description: Optional[str] = None
|
|
102
|
+
duration_seconds: Optional[float] = None
|
|
103
|
+
resolution: Optional[str] = None # "1920x1080"
|
|
104
|
+
language: Optional[str] = None # "ar"
|
|
105
|
+
subtitles: list[SubtitleRef] = field(default_factory=list)
|
|
106
|
+
thumbnail: Optional[ThumbnailMeta] = None
|
|
107
|
+
tags: list[str] = field(default_factory=list)
|
|
108
|
+
source_url: Optional[str] = None
|
|
109
|
+
content_rating: Optional[str] = None # "G", "PG-13", …
|
|
110
|
+
|
|
111
|
+
# سلامة وتشفير
|
|
112
|
+
sha256_original: str = ""
|
|
113
|
+
compression: str = "zlib"
|
|
114
|
+
encryption: str = "aes-256-gcm-pbkdf2"
|
|
115
|
+
format_version: int = FORMAT_VERSION
|
|
116
|
+
|
|
117
|
+
# توقيت
|
|
118
|
+
created_at: str = field(
|
|
119
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
120
|
+
)
|
|
121
|
+
extra: dict = field(default_factory=dict)
|
|
122
|
+
|
|
123
|
+
# ── تحويل ──────────────────────────────────────────────────────────
|
|
124
|
+
def to_dict(self) -> dict:
|
|
125
|
+
d = asdict(self)
|
|
126
|
+
return d
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def from_dict(cls, d: dict) -> "SeriesMetadata":
|
|
130
|
+
# إزالة الحقول المجهولة للتوافق الأمامي
|
|
131
|
+
known = {f.name for f in cls.__dataclass_fields__.values()} # type: ignore[attr-defined]
|
|
132
|
+
clean = {k: v for k, v in d.items() if k in known}
|
|
133
|
+
# إعادة بناء الكائنات المدمجة
|
|
134
|
+
if "subtitles" in clean and clean["subtitles"]:
|
|
135
|
+
clean["subtitles"] = [SubtitleRef(**s) if isinstance(s, dict) else s
|
|
136
|
+
for s in clean["subtitles"]]
|
|
137
|
+
if "thumbnail" in clean and isinstance(clean.get("thumbnail"), dict):
|
|
138
|
+
clean["thumbnail"] = ThumbnailMeta(**clean["thumbnail"])
|
|
139
|
+
return cls(**clean)
|
|
140
|
+
|
|
141
|
+
def to_json(self, indent: int | None = None) -> str:
|
|
142
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def from_json(cls, text: str) -> "SeriesMetadata":
|
|
146
|
+
return cls.from_dict(json.loads(text))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ───────────────────────────── key derivation ─────────────────────────
|
|
150
|
+
def _derive_key(password: str, salt: bytes) -> bytes:
|
|
151
|
+
kdf = PBKDF2HMAC(
|
|
152
|
+
algorithm=hashes.SHA256(),
|
|
153
|
+
length=KEY_SIZE,
|
|
154
|
+
salt=salt,
|
|
155
|
+
iterations=PBKDF2_ITER,
|
|
156
|
+
)
|
|
157
|
+
return kdf.derive(password.encode("utf-8"))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ───────────────────────────── helpers ────────────────────────────────
|
|
161
|
+
def _open_input(source: FileInput) -> tuple[BinaryIO, Optional[str]]:
|
|
162
|
+
"""يفتح مصدر البيانات ويعيد (stream, filename_or_None)."""
|
|
163
|
+
if isinstance(source, (str, Path)):
|
|
164
|
+
p = Path(source)
|
|
165
|
+
return open(p, "rb"), p.name
|
|
166
|
+
if isinstance(source, bytes):
|
|
167
|
+
return io.BytesIO(source), None
|
|
168
|
+
# BinaryIO
|
|
169
|
+
name = getattr(source, "name", None)
|
|
170
|
+
if name:
|
|
171
|
+
name = Path(name).name
|
|
172
|
+
return source, name
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _sha256_stream(stream: BinaryIO) -> str:
|
|
176
|
+
h = hashlib.sha256()
|
|
177
|
+
stream.seek(0)
|
|
178
|
+
while chunk := stream.read(CHUNK_SIZE):
|
|
179
|
+
h.update(chunk)
|
|
180
|
+
stream.seek(0)
|
|
181
|
+
return h.hexdigest()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ───────────────────────────── CREATE ─────────────────────────────────
|
|
185
|
+
def create_archive(
|
|
186
|
+
*,
|
|
187
|
+
source: FileInput,
|
|
188
|
+
dest: FileInput,
|
|
189
|
+
password: str,
|
|
190
|
+
series_name: str,
|
|
191
|
+
season: Optional[int] = None,
|
|
192
|
+
episode: Optional[int] = None,
|
|
193
|
+
title: Optional[str] = None,
|
|
194
|
+
description: Optional[str] = None,
|
|
195
|
+
duration: Optional[float] = None,
|
|
196
|
+
resolution: Optional[str] = None,
|
|
197
|
+
language: Optional[str] = None,
|
|
198
|
+
subtitles: Optional[list[SubtitleRef]] = None,
|
|
199
|
+
thumbnail: Optional[ThumbnailMeta] = None,
|
|
200
|
+
tags: Optional[list[str]] = None,
|
|
201
|
+
source_url: Optional[str] = None,
|
|
202
|
+
content_rating: Optional[str] = None,
|
|
203
|
+
extra: Optional[dict] = None,
|
|
204
|
+
compress_level: int = 6,
|
|
205
|
+
) -> "ArchiveResult":
|
|
206
|
+
"""
|
|
207
|
+
ينشئ أرشيف .tslsl من مصدر بيانات أي نوع.
|
|
208
|
+
|
|
209
|
+
Parameters
|
|
210
|
+
----------
|
|
211
|
+
source : path / bytes / BinaryIO — مصدر الفيديو
|
|
212
|
+
dest : path / BinaryIO — وجهة الكتابة
|
|
213
|
+
|
|
214
|
+
Returns
|
|
215
|
+
-------
|
|
216
|
+
ArchiveResult مع معلومات الملف الناتج
|
|
217
|
+
"""
|
|
218
|
+
in_stream, src_name = _open_input(source)
|
|
219
|
+
|
|
220
|
+
# SHA-256 على الأصل
|
|
221
|
+
sha256 = _sha256_stream(in_stream)
|
|
222
|
+
raw_data = in_stream.read()
|
|
223
|
+
in_stream.close()
|
|
224
|
+
|
|
225
|
+
# ضغط
|
|
226
|
+
compressed = zlib.compress(raw_data, level=compress_level)
|
|
227
|
+
|
|
228
|
+
# تشفير AES-256-GCM
|
|
229
|
+
salt = os.urandom(SALT_SIZE)
|
|
230
|
+
iv = os.urandom(IV_SIZE)
|
|
231
|
+
key = _derive_key(password, salt)
|
|
232
|
+
aesgcm = AESGCM(key)
|
|
233
|
+
ciphertext = aesgcm.encrypt(iv, compressed, None) # يشمل TAG في النهاية
|
|
234
|
+
|
|
235
|
+
# ميتاداتا
|
|
236
|
+
meta = SeriesMetadata(
|
|
237
|
+
series_name = series_name,
|
|
238
|
+
original_filename= src_name or "video.mp4",
|
|
239
|
+
season = season,
|
|
240
|
+
episode = episode,
|
|
241
|
+
title = title,
|
|
242
|
+
description = description,
|
|
243
|
+
duration_seconds = duration,
|
|
244
|
+
resolution = resolution,
|
|
245
|
+
language = language,
|
|
246
|
+
subtitles = subtitles or [],
|
|
247
|
+
thumbnail = thumbnail,
|
|
248
|
+
tags = tags or [],
|
|
249
|
+
source_url = source_url,
|
|
250
|
+
content_rating = content_rating,
|
|
251
|
+
sha256_original = sha256,
|
|
252
|
+
extra = extra or {},
|
|
253
|
+
)
|
|
254
|
+
header_bytes = meta.to_json().encode("utf-8")
|
|
255
|
+
|
|
256
|
+
# كتابة
|
|
257
|
+
if isinstance(dest, (str, Path)):
|
|
258
|
+
out_stream = open(dest, "wb")
|
|
259
|
+
out_path = Path(dest)
|
|
260
|
+
else:
|
|
261
|
+
out_stream = dest
|
|
262
|
+
out_path = None
|
|
263
|
+
|
|
264
|
+
out_stream.write(MAGIC)
|
|
265
|
+
out_stream.write(struct.pack(">H", FORMAT_VERSION))
|
|
266
|
+
out_stream.write(struct.pack(">I", len(header_bytes)))
|
|
267
|
+
out_stream.write(header_bytes)
|
|
268
|
+
out_stream.write(salt)
|
|
269
|
+
out_stream.write(iv)
|
|
270
|
+
out_stream.write(ciphertext)
|
|
271
|
+
|
|
272
|
+
size_out = out_stream.tell() if hasattr(out_stream, "tell") else None
|
|
273
|
+
if isinstance(dest, (str, Path)):
|
|
274
|
+
out_stream.close()
|
|
275
|
+
size_out = out_path.stat().st_size # type: ignore[union-attr]
|
|
276
|
+
|
|
277
|
+
return ArchiveResult(
|
|
278
|
+
metadata = meta,
|
|
279
|
+
path = out_path,
|
|
280
|
+
size_bytes= size_out or len(MAGIC) + 2 + 4 + len(header_bytes) + SALT_SIZE + IV_SIZE + len(ciphertext),
|
|
281
|
+
sha256_original = sha256,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ───────────────────────────── READ METADATA ──────────────────────────
|
|
286
|
+
def read_metadata(source: FileInput) -> SeriesMetadata:
|
|
287
|
+
"""يقرأ ميتاداتا الأرشيف بدون كلمة سر."""
|
|
288
|
+
stream, _ = _open_input(source)
|
|
289
|
+
try:
|
|
290
|
+
magic = stream.read(len(MAGIC))
|
|
291
|
+
if magic != MAGIC:
|
|
292
|
+
# دعم v1 للتوافق الخلفي
|
|
293
|
+
if magic == b"TSLSL001":
|
|
294
|
+
raise UnsupportedVersionError(
|
|
295
|
+
"هذا أرشيف v1 — استخدم tasalsul < 0.2 للقراءة أو حوّله بـ migrate_v1()"
|
|
296
|
+
)
|
|
297
|
+
raise CorruptArchiveError("الملف ليس أرشيف تسلسل صالحًا")
|
|
298
|
+
|
|
299
|
+
ver = struct.unpack(">H", stream.read(2))[0]
|
|
300
|
+
if ver != FORMAT_VERSION:
|
|
301
|
+
raise UnsupportedVersionError(f"إصدار الصيغة {ver} غير مدعوم")
|
|
302
|
+
|
|
303
|
+
(header_len,) = struct.unpack(">I", stream.read(4))
|
|
304
|
+
header_bytes = stream.read(header_len)
|
|
305
|
+
return SeriesMetadata.from_dict(json.loads(header_bytes.decode("utf-8")))
|
|
306
|
+
except (TasalsulError, UnsupportedVersionError):
|
|
307
|
+
raise
|
|
308
|
+
except Exception as exc:
|
|
309
|
+
raise CorruptArchiveError(f"فشل قراءة الأرشيف: {exc}") from exc
|
|
310
|
+
finally:
|
|
311
|
+
stream.close()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ───────────────────────────── EXTRACT ────────────────────────────────
|
|
315
|
+
def extract_archive(
|
|
316
|
+
*,
|
|
317
|
+
source: FileInput,
|
|
318
|
+
dest: FileInput,
|
|
319
|
+
password: str,
|
|
320
|
+
verify_checksum: bool = True,
|
|
321
|
+
) -> "ExtractResult":
|
|
322
|
+
"""
|
|
323
|
+
يفك تشفير وضغط أرشيف .tslsl.
|
|
324
|
+
|
|
325
|
+
Returns
|
|
326
|
+
-------
|
|
327
|
+
ExtractResult مع بيانات الملف المستخرج
|
|
328
|
+
"""
|
|
329
|
+
in_stream, _ = _open_input(source)
|
|
330
|
+
try:
|
|
331
|
+
magic = in_stream.read(len(MAGIC))
|
|
332
|
+
if magic != MAGIC:
|
|
333
|
+
if magic == b"TSLSL001":
|
|
334
|
+
raise UnsupportedVersionError("أرشيف v1 — استخدم migrate_v1() أولاً")
|
|
335
|
+
raise CorruptArchiveError("الملف ليس أرشيف تسلسل صالحًا")
|
|
336
|
+
|
|
337
|
+
ver = struct.unpack(">H", in_stream.read(2))[0]
|
|
338
|
+
if ver != FORMAT_VERSION:
|
|
339
|
+
raise UnsupportedVersionError(f"إصدار {ver} غير مدعوم")
|
|
340
|
+
|
|
341
|
+
(header_len,) = struct.unpack(">I", in_stream.read(4))
|
|
342
|
+
header_bytes = in_stream.read(header_len)
|
|
343
|
+
meta = SeriesMetadata.from_dict(json.loads(header_bytes.decode("utf-8")))
|
|
344
|
+
|
|
345
|
+
salt = in_stream.read(SALT_SIZE)
|
|
346
|
+
iv = in_stream.read(IV_SIZE)
|
|
347
|
+
ciphertext= in_stream.read()
|
|
348
|
+
finally:
|
|
349
|
+
in_stream.close()
|
|
350
|
+
|
|
351
|
+
# فك التشفير
|
|
352
|
+
key = _derive_key(password, salt)
|
|
353
|
+
aesgcm = AESGCM(key)
|
|
354
|
+
try:
|
|
355
|
+
compressed = aesgcm.decrypt(iv, ciphertext, None)
|
|
356
|
+
except Exception as exc:
|
|
357
|
+
raise InvalidPasswordError("كلمة السر خاطئة أو الأرشيف تالف") from exc
|
|
358
|
+
|
|
359
|
+
# فك الضغط
|
|
360
|
+
try:
|
|
361
|
+
raw_data = zlib.decompress(compressed)
|
|
362
|
+
except zlib.error as exc:
|
|
363
|
+
raise CorruptArchiveError(f"فشل فك الضغط: {exc}") from exc
|
|
364
|
+
|
|
365
|
+
# التحقق من السلامة
|
|
366
|
+
if verify_checksum and meta.sha256_original:
|
|
367
|
+
actual = hashlib.sha256(raw_data).hexdigest()
|
|
368
|
+
if actual != meta.sha256_original:
|
|
369
|
+
raise CorruptArchiveError("فشل التحقق من السلامة (SHA-256 mismatch)")
|
|
370
|
+
|
|
371
|
+
# كتابة المخرج
|
|
372
|
+
if isinstance(dest, (str, Path)):
|
|
373
|
+
out_path = Path(dest)
|
|
374
|
+
out_path.write_bytes(raw_data)
|
|
375
|
+
else:
|
|
376
|
+
dest.write(raw_data)
|
|
377
|
+
out_path = None
|
|
378
|
+
|
|
379
|
+
return ExtractResult(metadata=meta, path=out_path, size_bytes=len(raw_data))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ───────────────────────────── MIGRATE v1→v2 ──────────────────────────
|
|
383
|
+
def migrate_v1(
|
|
384
|
+
*,
|
|
385
|
+
source: FileInput,
|
|
386
|
+
dest: FileInput,
|
|
387
|
+
password: str,
|
|
388
|
+
) -> "ArchiveResult":
|
|
389
|
+
"""يحوّل أرشيف v1 (.tslsl v1) إلى v2 مع الاحتفاظ بالميتاداتا."""
|
|
390
|
+
# استيراد منطق v1 المحلي المبسّط
|
|
391
|
+
from cryptography.fernet import Fernet as _Fernet
|
|
392
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC as _KDF
|
|
393
|
+
from cryptography.hazmat.primitives import hashes as _hashes
|
|
394
|
+
|
|
395
|
+
in_stream, _ = _open_input(source)
|
|
396
|
+
try:
|
|
397
|
+
magic = in_stream.read(8)
|
|
398
|
+
if magic != b"TSLSL001":
|
|
399
|
+
raise CorruptArchiveError("ليس أرشيف v1")
|
|
400
|
+
(hl,) = struct.unpack(">I", in_stream.read(4))
|
|
401
|
+
hdr = json.loads(in_stream.read(hl).decode())
|
|
402
|
+
salt_v1 = in_stream.read(16)
|
|
403
|
+
encrypted = in_stream.read()
|
|
404
|
+
finally:
|
|
405
|
+
in_stream.close()
|
|
406
|
+
|
|
407
|
+
kdf = _KDF(algorithm=_hashes.SHA256(), length=32, salt=salt_v1,
|
|
408
|
+
iterations=390_000)
|
|
409
|
+
raw_key = kdf.derive(password.encode())
|
|
410
|
+
fernet = _Fernet(base64.urlsafe_b64encode(raw_key))
|
|
411
|
+
try:
|
|
412
|
+
compressed = fernet.decrypt(encrypted)
|
|
413
|
+
except Exception as exc:
|
|
414
|
+
raise InvalidPasswordError("كلمة السر خاطئة") from exc
|
|
415
|
+
|
|
416
|
+
raw_data = zlib.decompress(compressed)
|
|
417
|
+
|
|
418
|
+
return create_archive(
|
|
419
|
+
source = raw_data,
|
|
420
|
+
dest = dest,
|
|
421
|
+
password = password,
|
|
422
|
+
series_name = hdr.get("series_name", ""),
|
|
423
|
+
season = hdr.get("season"),
|
|
424
|
+
episode = hdr.get("episode"),
|
|
425
|
+
title = hdr.get("title"),
|
|
426
|
+
extra = {"migrated_from": "v1"},
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ───────────────────────────── RESULT TYPES ───────────────────────────
|
|
431
|
+
@dataclass
|
|
432
|
+
class ArchiveResult:
|
|
433
|
+
metadata: SeriesMetadata
|
|
434
|
+
path: Optional[Path]
|
|
435
|
+
size_bytes: int
|
|
436
|
+
sha256_original: str
|
|
437
|
+
|
|
438
|
+
def __str__(self) -> str:
|
|
439
|
+
mb = self.size_bytes / 1_048_576
|
|
440
|
+
return (f"ArchiveResult(series={self.metadata.series_name!r}, "
|
|
441
|
+
f"size={mb:.2f} MB, path={self.path})")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@dataclass
|
|
445
|
+
class ExtractResult:
|
|
446
|
+
metadata: SeriesMetadata
|
|
447
|
+
path: Optional[Path]
|
|
448
|
+
size_bytes: int
|
|
449
|
+
|
|
450
|
+
def __str__(self) -> str:
|
|
451
|
+
mb = self.size_bytes / 1_048_576
|
|
452
|
+
return (f"ExtractResult(series={self.metadata.series_name!r}, "
|
|
453
|
+
f"size={mb:.2f} MB, path={self.path})")
|
tasalsul/format.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
تسلسل (TSLSL) - صيغة أرشيف مضغوط ومشفر مخصصة لفيديوهات المسلسلات.
|
|
3
|
+
|
|
4
|
+
بنية الملف (.tslsl):
|
|
5
|
+
[8 bytes] MAGIC = b'TSLSL001'
|
|
6
|
+
[4 bytes] HEADER_LEN = طول الهيدر بالبايت (uint32, big-endian)
|
|
7
|
+
[N bytes] HEADER = JSON بالميتاداتا (مشفّر بـ UTF-8، غير معمّى)
|
|
8
|
+
[16 bytes] SALT = ملح عشوائي لاستخراج مفتاح التشفير من كلمة السر
|
|
9
|
+
[M bytes] PAYLOAD = البيانات (مضغوطة zlib ثم مشفّرة AES/Fernet)
|
|
10
|
+
|
|
11
|
+
الميتاداتا في الهيدر تتضمن: اسم المسلسل، الموسم، الحلقة، اسم الملف
|
|
12
|
+
الأصلي، خوارزميات الضغط/التشفير، وبصمة (hash) للتحقق من السلامة.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import struct
|
|
22
|
+
import zlib
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
from cryptography.fernet import Fernet
|
|
29
|
+
from cryptography.hazmat.primitives import hashes
|
|
30
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
31
|
+
|
|
32
|
+
MAGIC = b"TSLSL001"
|
|
33
|
+
SALT_SIZE = 16
|
|
34
|
+
PBKDF2_ITERATIONS = 390_000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TasalsulError(Exception):
|
|
38
|
+
"""خطأ عام في معالجة أرشيف تسلسل."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class InvalidPasswordError(TasalsulError):
|
|
42
|
+
"""كلمة السر غير صحيحة أو الملف تالف."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CorruptArchiveError(TasalsulError):
|
|
46
|
+
"""الأرشيف تالف أو لا يطابق صيغة تسلسل."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class SeriesMetadata:
|
|
51
|
+
"""ميتاداتا الحلقة المخزّنة داخل الأرشيف."""
|
|
52
|
+
|
|
53
|
+
series_name: str
|
|
54
|
+
original_filename: str
|
|
55
|
+
season: Optional[int] = None
|
|
56
|
+
episode: Optional[int] = None
|
|
57
|
+
title: Optional[str] = None
|
|
58
|
+
created_at: str = field(
|
|
59
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
60
|
+
)
|
|
61
|
+
compression: str = "zlib"
|
|
62
|
+
encryption: str = "fernet-aes128-pbkdf2"
|
|
63
|
+
sha256_original: str = ""
|
|
64
|
+
extra: dict = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict:
|
|
67
|
+
return self.__dict__.copy()
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def from_dict(cls, d: dict) -> "SeriesMetadata":
|
|
71
|
+
return cls(**d)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _derive_key(password: str, salt: bytes) -> bytes:
|
|
75
|
+
"""يستخرج مفتاح Fernet (32 بايت base64) من كلمة السر + الملح."""
|
|
76
|
+
kdf = PBKDF2HMAC(
|
|
77
|
+
algorithm=hashes.SHA256(),
|
|
78
|
+
length=32,
|
|
79
|
+
salt=salt,
|
|
80
|
+
iterations=PBKDF2_ITERATIONS,
|
|
81
|
+
)
|
|
82
|
+
raw_key = kdf.derive(password.encode("utf-8"))
|
|
83
|
+
return base64.urlsafe_b64encode(raw_key)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def create_archive(
|
|
87
|
+
input_path: str | Path,
|
|
88
|
+
output_path: str | Path,
|
|
89
|
+
password: str,
|
|
90
|
+
series_name: str,
|
|
91
|
+
season: Optional[int] = None,
|
|
92
|
+
episode: Optional[int] = None,
|
|
93
|
+
title: Optional[str] = None,
|
|
94
|
+
compress_level: int = 6,
|
|
95
|
+
) -> Path:
|
|
96
|
+
"""يحوّل ملف فيديو إلى أرشيف .tslsl مضغوط ومشفّر."""
|
|
97
|
+
input_path = Path(input_path)
|
|
98
|
+
output_path = Path(output_path)
|
|
99
|
+
|
|
100
|
+
if not input_path.is_file():
|
|
101
|
+
raise FileNotFoundError(f"الملف غير موجود: {input_path}")
|
|
102
|
+
|
|
103
|
+
raw_data = input_path.read_bytes()
|
|
104
|
+
sha256_original = hashlib.sha256(raw_data).hexdigest()
|
|
105
|
+
|
|
106
|
+
compressed = zlib.compress(raw_data, level=compress_level)
|
|
107
|
+
|
|
108
|
+
salt = os.urandom(SALT_SIZE)
|
|
109
|
+
key = _derive_key(password, salt)
|
|
110
|
+
fernet = Fernet(key)
|
|
111
|
+
encrypted = fernet.encrypt(compressed)
|
|
112
|
+
|
|
113
|
+
metadata = SeriesMetadata(
|
|
114
|
+
series_name=series_name,
|
|
115
|
+
original_filename=input_path.name,
|
|
116
|
+
season=season,
|
|
117
|
+
episode=episode,
|
|
118
|
+
title=title,
|
|
119
|
+
sha256_original=sha256_original,
|
|
120
|
+
)
|
|
121
|
+
header_bytes = json.dumps(metadata.to_dict(), ensure_ascii=False).encode("utf-8")
|
|
122
|
+
|
|
123
|
+
with open(output_path, "wb") as f:
|
|
124
|
+
f.write(MAGIC)
|
|
125
|
+
f.write(struct.pack(">I", len(header_bytes)))
|
|
126
|
+
f.write(header_bytes)
|
|
127
|
+
f.write(salt)
|
|
128
|
+
f.write(encrypted)
|
|
129
|
+
|
|
130
|
+
return output_path
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def read_metadata(archive_path: str | Path) -> SeriesMetadata:
|
|
134
|
+
"""يقرأ ميتاداتا الأرشيف بدون الحاجة لكلمة السر."""
|
|
135
|
+
archive_path = Path(archive_path)
|
|
136
|
+
with open(archive_path, "rb") as f:
|
|
137
|
+
magic = f.read(len(MAGIC))
|
|
138
|
+
if magic != MAGIC:
|
|
139
|
+
raise CorruptArchiveError("الملف ليس أرشيف تسلسل (.tslsl) صالحًا")
|
|
140
|
+
|
|
141
|
+
(header_len,) = struct.unpack(">I", f.read(4))
|
|
142
|
+
header_bytes = f.read(header_len)
|
|
143
|
+
try:
|
|
144
|
+
header_dict = json.loads(header_bytes.decode("utf-8"))
|
|
145
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
146
|
+
raise CorruptArchiveError("هيدر الأرشيف تالف") from exc
|
|
147
|
+
|
|
148
|
+
return SeriesMetadata.from_dict(header_dict)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def extract_archive(
|
|
152
|
+
archive_path: str | Path,
|
|
153
|
+
output_path: str | Path,
|
|
154
|
+
password: str,
|
|
155
|
+
verify_checksum: bool = True,
|
|
156
|
+
) -> Path:
|
|
157
|
+
"""يفك تشفير وضغط أرشيف .tslsl ويستعيد الفيديو الأصلي."""
|
|
158
|
+
archive_path = Path(archive_path)
|
|
159
|
+
output_path = Path(output_path)
|
|
160
|
+
|
|
161
|
+
with open(archive_path, "rb") as f:
|
|
162
|
+
magic = f.read(len(MAGIC))
|
|
163
|
+
if magic != MAGIC:
|
|
164
|
+
raise CorruptArchiveError("الملف ليس أرشيف تسلسل (.tslsl) صالحًا")
|
|
165
|
+
|
|
166
|
+
(header_len,) = struct.unpack(">I", f.read(4))
|
|
167
|
+
header_bytes = f.read(header_len)
|
|
168
|
+
metadata = SeriesMetadata.from_dict(json.loads(header_bytes.decode("utf-8")))
|
|
169
|
+
|
|
170
|
+
salt = f.read(SALT_SIZE)
|
|
171
|
+
encrypted = f.read()
|
|
172
|
+
|
|
173
|
+
key = _derive_key(password, salt)
|
|
174
|
+
fernet = Fernet(key)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
compressed = fernet.decrypt(encrypted)
|
|
178
|
+
except Exception as exc: # InvalidToken وغيرها
|
|
179
|
+
raise InvalidPasswordError("كلمة السر غير صحيحة أو الأرشيف تالف") from exc
|
|
180
|
+
|
|
181
|
+
raw_data = zlib.decompress(compressed)
|
|
182
|
+
|
|
183
|
+
if verify_checksum and metadata.sha256_original:
|
|
184
|
+
actual = hashlib.sha256(raw_data).hexdigest()
|
|
185
|
+
if actual != metadata.sha256_original:
|
|
186
|
+
raise CorruptArchiveError("فشل التحقق من سلامة الملف (checksum mismatch)")
|
|
187
|
+
|
|
188
|
+
output_path.write_bytes(raw_data)
|
|
189
|
+
return output_path
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tasalsul
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: تسلسل: أرشيف مضغوط ومشفّر للمسلسلات (.tslsl v2)
|
|
5
|
+
Author: Tasalsul Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/syrian-vcg/tasalsul
|
|
8
|
+
Project-URL: Repository, https://github.com/syrian-vcg/tasalsul
|
|
9
|
+
Project-URL: Issues, https://github.com/syrian-vcg/tasalsul/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/syrian-vcg/tasalsul/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: archive,encryption,video,series,tslsl
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Multimedia :: Video
|
|
21
|
+
Classifier: Topic :: Security :: Cryptography
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: cryptography>=42.0.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
29
|
+
Provides-Extra: docs
|
|
30
|
+
Requires-Dist: mkdocs-material; extra == "docs"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# تسلسل (Tasalsul) 📦🔐
|
|
34
|
+
|
|
35
|
+
<p align="center">
|
|
36
|
+
<img src="assets/tslsl-icon.svg" width="80" alt="تسلسل أيقونة"/>
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
<p align="center">
|
|
40
|
+
<a href="https://pypi.org/project/tasalsul/"><img src="https://img.shields.io/pypi/v/tasalsul?label=PyPI&color=6C63FF" alt="PyPI"/></a>
|
|
41
|
+
<a href="https://www.npmjs.com/package/tasalsul"><img src="https://img.shields.io/npm/v/tasalsul?label=npm&color=FF6584" alt="npm"/></a>
|
|
42
|
+
<img src="https://img.shields.io/pypi/pyversions/tasalsul?color=4ADE80" alt="Python"/>
|
|
43
|
+
<img src="https://img.shields.io/github/license/YOUR_USERNAME/tasalsul" alt="License"/>
|
|
44
|
+
<img src="https://img.shields.io/github/actions/workflow/status/YOUR_USERNAME/tasalsul/ci.yml?label=CI" alt="CI"/>
|
|
45
|
+
</p>
|
|
46
|
+
|
|
47
|
+
**تسلسل** هي صيغة أرشيف مفتوحة المصدر (`.tslsl`) لضغط وتشفير فيديوهات المسلسلات بشكل آمن، مع ميتاداتا قابلة للقراءة بلا كلمة سر.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## ✨ الميزات
|
|
52
|
+
|
|
53
|
+
| الميزة | التفاصيل |
|
|
54
|
+
|--------|----------|
|
|
55
|
+
| 🔐 تشفير قوي | AES-256-GCM مع PBKDF2 (390,000 تكرار) |
|
|
56
|
+
| 📦 ضغط | zlib — تقليص ملحوظ للحجم |
|
|
57
|
+
| 📋 ميتاداتا غنية | اسم، موسم، حلقة، وصف، لغة، ترجمات، صورة مصغّرة |
|
|
58
|
+
| 🌐 دعم الويب | JavaScript SDK للمتصفح و Node.js |
|
|
59
|
+
| 🔄 بث (streaming) | يعمل مع ملفات ضخمة بكفاءة |
|
|
60
|
+
| ✅ سلامة البيانات | SHA-256 للتحقق من الملف الأصلي |
|
|
61
|
+
| 🔁 ترقية v1→v2 | أمر `migrate` مدمج |
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 📥 التثبيت
|
|
66
|
+
|
|
67
|
+
### Python
|
|
68
|
+
```bash
|
|
69
|
+
pip install tasalsul
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### JavaScript / Node.js
|
|
73
|
+
```bash
|
|
74
|
+
npm install tasalsul
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### من المصدر
|
|
78
|
+
```bash
|
|
79
|
+
git clone https://github.com/YOUR_USERNAME/tasalsul
|
|
80
|
+
cd tasalsul
|
|
81
|
+
pip install -e ".[dev]"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 🚀 الاستخدام
|
|
87
|
+
|
|
88
|
+
### Python API
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from tasalsul import create_archive, extract_archive, read_metadata, ThumbnailMeta
|
|
92
|
+
|
|
93
|
+
# إنشاء أرشيف
|
|
94
|
+
result = create_archive(
|
|
95
|
+
source = "episode01.mp4",
|
|
96
|
+
dest = "episode01.tslsl",
|
|
97
|
+
password = "كلمة_سرية",
|
|
98
|
+
series_name = "Breaking Bad",
|
|
99
|
+
season = 1,
|
|
100
|
+
episode = 1,
|
|
101
|
+
title = "Pilot",
|
|
102
|
+
description = "والتر وايت يبدأ رحلته",
|
|
103
|
+
language = "ar",
|
|
104
|
+
resolution = "1920x1080",
|
|
105
|
+
duration = 3180.0,
|
|
106
|
+
tags = ["drama", "crime"],
|
|
107
|
+
content_rating = "TV-MA",
|
|
108
|
+
)
|
|
109
|
+
print(result) # ArchiveResult(series='Breaking Bad', size=42.5 MB, path=...)
|
|
110
|
+
|
|
111
|
+
# قراءة الميتاداتا (بلا كلمة سر)
|
|
112
|
+
meta = read_metadata("episode01.tslsl")
|
|
113
|
+
print(meta.series_name) # Breaking Bad
|
|
114
|
+
print(meta.format_version) # 2
|
|
115
|
+
|
|
116
|
+
# استخراج الفيديو
|
|
117
|
+
extract_archive(
|
|
118
|
+
source = "episode01.tslsl",
|
|
119
|
+
dest = "restored.mp4",
|
|
120
|
+
password = "كلمة_سرية",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# الاستخدام مع bytes / BytesIO
|
|
124
|
+
import io
|
|
125
|
+
video_bytes = open("ep.mp4", "rb").read()
|
|
126
|
+
buf = io.BytesIO()
|
|
127
|
+
create_archive(source=video_bytes, dest=buf, password="pw", series_name="Test")
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### JavaScript (Browser)
|
|
131
|
+
|
|
132
|
+
```html
|
|
133
|
+
<script type="module">
|
|
134
|
+
import { createArchive, extractArchive, readMetadata,
|
|
135
|
+
readFile, downloadBlob } from "https://unpkg.com/tasalsul/src/tasalsul/tasalsul.js";
|
|
136
|
+
|
|
137
|
+
// قراءة ملف من المستخدم
|
|
138
|
+
const file = document.querySelector('input').files[0];
|
|
139
|
+
const data = await readFile(file);
|
|
140
|
+
|
|
141
|
+
// إنشاء أرشيف
|
|
142
|
+
const archive = await createArchive(data, "password", {
|
|
143
|
+
series_name: "Breaking Bad",
|
|
144
|
+
original_filename: file.name,
|
|
145
|
+
season: 1, episode: 1,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// تحميل الأرشيف
|
|
149
|
+
downloadBlob(new Blob([archive]), "ep1.tslsl");
|
|
150
|
+
|
|
151
|
+
// قراءة الميتاداتا
|
|
152
|
+
const meta = readMetadata(archive);
|
|
153
|
+
console.log(meta.series_name); // Breaking Bad
|
|
154
|
+
</script>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### JavaScript (Node.js)
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
import { readFile } from "fs/promises";
|
|
161
|
+
import { createArchive, extractArchive, readMetadata } from "tasalsul";
|
|
162
|
+
|
|
163
|
+
const video = new Uint8Array(await readFile("ep.mp4"));
|
|
164
|
+
const archive = await createArchive(video, "password", {
|
|
165
|
+
series_name: "My Series", season: 1, episode: 1,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const { data, metadata } = await extractArchive(archive, "password");
|
|
169
|
+
console.log(metadata.series_name);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### CLI
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
# إنشاء أرشيف مع ميتاداتا كاملة
|
|
176
|
+
tslsl create episode.mp4 \
|
|
177
|
+
--series "Breaking Bad" --season 1 --episode 1 \
|
|
178
|
+
--title "Pilot" --description "بداية القصة" \
|
|
179
|
+
--language ar --resolution 1920x1080 --duration 3180 \
|
|
180
|
+
--tag drama --tag crime \
|
|
181
|
+
--thumbnail thumb.jpg \
|
|
182
|
+
--subtitle ar:subs_ar.srt \
|
|
183
|
+
-o episode.tslsl
|
|
184
|
+
|
|
185
|
+
# عرض الميتاداتا
|
|
186
|
+
tslsl info episode.tslsl
|
|
187
|
+
tslsl info episode.tslsl --json # مخرج JSON
|
|
188
|
+
|
|
189
|
+
# استخراج الفيديو
|
|
190
|
+
tslsl extract episode.tslsl -o restored.mp4
|
|
191
|
+
|
|
192
|
+
# ترقية أرشيف v1
|
|
193
|
+
tslsl migrate old_episode.tslsl -o new_episode.tslsl
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## 🧱 بنية الملف (.tslsl v2)
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
┌──────────────┬─────────────────────────────────────────┐
|
|
202
|
+
│ 8 bytes │ MAGIC = "TSLSL002" │
|
|
203
|
+
│ 2 bytes │ FORMAT_VERSION = 2 │
|
|
204
|
+
│ 4 bytes │ HEADER_LEN │
|
|
205
|
+
│ N bytes │ HEADER (JSON / UTF-8, غير مشفّر) │
|
|
206
|
+
│ 16 bytes │ SALT (عشوائي — لاشتقاق المفتاح) │
|
|
207
|
+
│ 16 bytes │ IV (nonce AES-GCM) │
|
|
208
|
+
│ M bytes │ PAYLOAD (zlib ثم AES-256-GCM) │
|
|
209
|
+
│ 16 bytes │ GCM AUTH TAG (مدمج) │
|
|
210
|
+
└──────────────┴─────────────────────────────────────────┘
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## 🔐 الأمان
|
|
216
|
+
|
|
217
|
+
- **AES-256-GCM**: تشفير مصادق عليه — يكتشف أي تلاعب في البيانات
|
|
218
|
+
- **PBKDF2-SHA256 (390,000 تكرار)**: حماية ضد هجمات القوة الغاشمة
|
|
219
|
+
- **Salt عشوائي**: كل أرشيف مشفّر بمفتاح فريد حتى لو كانت كلمة السر ذاتها
|
|
220
|
+
- **SHA-256**: فحص السلامة بعد فك التشفير
|
|
221
|
+
|
|
222
|
+
> ⚠️ لا يمكن استعادة الملف بدون كلمة السر — التشفير غير قابل للكسر عملياً.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## 🧪 الاختبارات
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
pip install -e ".[dev]"
|
|
230
|
+
pytest -v
|
|
231
|
+
pytest --cov=tasalsul # مع تغطية الكود
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## 🤝 المساهمة
|
|
237
|
+
|
|
238
|
+
المساهمات مرحّب بها! افتح issue أو pull request.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## 📄 الترخيص
|
|
243
|
+
|
|
244
|
+
MIT © Tasalsul Contributors
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
tasalsul/__init__.py,sha256=UHkvvnISfD_G2ZtNEqNpfpUhr68smAxmArk7E9JIdOI,1374
|
|
2
|
+
tasalsul/cli.py,sha256=mQ9FO7xg-GytLoPAuUnJ6dHKB6E6aunELjrQGGeXj9g,8609
|
|
3
|
+
tasalsul/engine.py,sha256=r8FBqm5Qnjc38nzBbRyhRyRL1UkpLKwgvYJrwEwf-6A,17900
|
|
4
|
+
tasalsul/format.py,sha256=R2-o1ILeMm1fNdOm_zA2bINFApvlCYbT87vuccJ5jtk,6268
|
|
5
|
+
tasalsul-0.2.0.dist-info/licenses/LICENSE,sha256=EmzO-bT5SC-Pu73MQ7edpp3LYiCUigtBDYrbHYz98J4,1078
|
|
6
|
+
tasalsul-0.2.0.dist-info/METADATA,sha256=D2p7oRpt0shAW0QRVFQ0k4xeE_yT6e2KeGSVJdhMpzg,7982
|
|
7
|
+
tasalsul-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
tasalsul-0.2.0.dist-info/entry_points.txt,sha256=Cuttl6yF4Nc8caaMyJLxvJfqSj5SDoebMYX5SuIEdJg,44
|
|
9
|
+
tasalsul-0.2.0.dist-info/top_level.txt,sha256=Xssk3PWYtecLW5jm9JFecDMLdkf492i0coH1IrOmEP4,9
|
|
10
|
+
tasalsul-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tasalsul Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tasalsul
|