savehub-cli 1.0.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.
- savehub_cli/__init__.py +8 -0
- savehub_cli/config.py +25 -0
- savehub_cli/main.py +73 -0
- savehub_cli/modules/__init__.py +12 -0
- savehub_cli/modules/downloader.py +415 -0
- savehub_cli/modules/menu.py +348 -0
- savehub_cli/modules/utils.py +85 -0
- savehub_cli/translations.py +119 -0
- savehub_cli-1.0.0.dist-info/METADATA +126 -0
- savehub_cli-1.0.0.dist-info/RECORD +14 -0
- savehub_cli-1.0.0.dist-info/WHEEL +5 -0
- savehub_cli-1.0.0.dist-info/entry_points.txt +3 -0
- savehub_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- savehub_cli-1.0.0.dist-info/top_level.txt +1 -0
savehub_cli/__init__.py
ADDED
savehub_cli/config.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# SaveHub CLI - Configuration
|
|
2
|
+
|
|
3
|
+
LANGUAGES = ['AZ', 'EN', 'TR']
|
|
4
|
+
|
|
5
|
+
PLATFORM_CONFIG = {
|
|
6
|
+
'YouTube': {'patterns': ['youtube.com', 'youtu.be'], 'supports': ['video', 'playlist', 'audio']},
|
|
7
|
+
'Instagram': {'patterns': ['instagram.com'], 'supports': ['video', 'photo']},
|
|
8
|
+
'Facebook': {'patterns': ['facebook.com', 'fb.watch'], 'supports': ['video']},
|
|
9
|
+
'Twitter/X': {'patterns': ['twitter.com', 'x.com'], 'supports': ['video', 'gif']},
|
|
10
|
+
'LinkedIn': {'patterns': ['linkedin.com'], 'supports': ['video']},
|
|
11
|
+
'Snapchat': {'patterns': ['snapchat.com'], 'supports': ['video']},
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
FORMAT_SETTINGS = {
|
|
15
|
+
'date_format': '%Y-%m-%d',
|
|
16
|
+
'time_format': '%H:%M:%S',
|
|
17
|
+
'log_filename_format': '%Y-%m-%d',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
DOWNLOAD_SETTINGS = {
|
|
21
|
+
'output_dir': 'downloads',
|
|
22
|
+
'log_dir': 'logs',
|
|
23
|
+
'mp3_quality': '192',
|
|
24
|
+
'default_format': 'mp4',
|
|
25
|
+
}
|
savehub_cli/main.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SaveHub CLI - Main Entry Point
|
|
3
|
+
Multilingual Multimedia Downloader
|
|
4
|
+
Supports: Azərbaycanca (AZ), English (EN), Türkçe (TR)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
"""Main application entry point — called by pip-installed console script."""
|
|
14
|
+
|
|
15
|
+
# Ensure working directories exist in user's CWD (not package dir)
|
|
16
|
+
Path('downloads').mkdir(exist_ok=True)
|
|
17
|
+
Path('logs').mkdir(exist_ok=True)
|
|
18
|
+
|
|
19
|
+
from savehub_cli.modules.menu import select_language, show_main_menu, display_header
|
|
20
|
+
from savehub_cli.modules.menu import show_documentation, show_terms, show_feedback
|
|
21
|
+
from savehub_cli.modules.downloader import start_download
|
|
22
|
+
from savehub_cli.translations import get_text
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
language = select_language()
|
|
26
|
+
|
|
27
|
+
while True:
|
|
28
|
+
os.system('cls' if os.name == 'nt' else 'clear')
|
|
29
|
+
display_header(language)
|
|
30
|
+
|
|
31
|
+
choice = show_main_menu(language)
|
|
32
|
+
|
|
33
|
+
if choice == 'option_docs':
|
|
34
|
+
os.system('cls' if os.name == 'nt' else 'clear')
|
|
35
|
+
show_documentation(language)
|
|
36
|
+
input(f"\n{get_text('press_enter', language)}")
|
|
37
|
+
|
|
38
|
+
elif choice == 'option_terms':
|
|
39
|
+
os.system('cls' if os.name == 'nt' else 'clear')
|
|
40
|
+
show_terms(language)
|
|
41
|
+
input(f"\n{get_text('press_enter', language)}")
|
|
42
|
+
|
|
43
|
+
elif choice == 'option_feedback':
|
|
44
|
+
os.system('cls' if os.name == 'nt' else 'clear')
|
|
45
|
+
show_feedback(language)
|
|
46
|
+
input(f"\n{get_text('press_enter', language)}")
|
|
47
|
+
|
|
48
|
+
elif choice == 'option_start':
|
|
49
|
+
start_download(language)
|
|
50
|
+
|
|
51
|
+
elif choice == 'option_exit':
|
|
52
|
+
os.system('cls' if os.name == 'nt' else 'clear')
|
|
53
|
+
print(f"\n{get_text('goodbye', language)}")
|
|
54
|
+
print(f"{get_text('see_you', language)}\n")
|
|
55
|
+
break
|
|
56
|
+
|
|
57
|
+
except (KeyboardInterrupt, EOFError):
|
|
58
|
+
print("\n\n👋 Goodbye!")
|
|
59
|
+
sys.exit(0)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
import traceback
|
|
62
|
+
log_msg = traceback.format_exc()
|
|
63
|
+
try:
|
|
64
|
+
from savehub_cli.modules.utils import log_event
|
|
65
|
+
log_event(f"Unhandled exception: {log_msg}", 'ERROR')
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
print("\n❌ Unexpected error occurred. Check logs for details.")
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
main()
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SaveHub CLI - Download Module
|
|
3
|
+
Uses yt-dlp for multimedia downloading
|
|
4
|
+
Database-ready download logging structure
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from questionary import text as questionary_text, select
|
|
13
|
+
import yt_dlp
|
|
14
|
+
from savehub_cli.translations import get_text
|
|
15
|
+
import logging
|
|
16
|
+
from savehub_cli.modules.utils import validate_url, get_platform_from_url, log_event
|
|
17
|
+
|
|
18
|
+
# Suppress all console log output — logs go to file only
|
|
19
|
+
logging.getLogger().setLevel(logging.CRITICAL)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ─────────────────────────────────────────────
|
|
23
|
+
# Database-ready download record structure
|
|
24
|
+
# TODO: Replace with actual DB insert when DB is connected
|
|
25
|
+
# ─────────────────────────────────────────────
|
|
26
|
+
def create_download_record(url, platform, format_type, filename, status,
|
|
27
|
+
error=None, duration_sec=None, file_size_bytes=None):
|
|
28
|
+
"""
|
|
29
|
+
Creates a structured download record.
|
|
30
|
+
Ready to be inserted into a database table:
|
|
31
|
+
|
|
32
|
+
Table: downloads
|
|
33
|
+
┌──────────────────┬──────────────┐
|
|
34
|
+
│ Column │ Type │
|
|
35
|
+
├──────────────────┼──────────────┤
|
|
36
|
+
│ id │ INTEGER PK │
|
|
37
|
+
│ url │ TEXT │
|
|
38
|
+
│ platform │ TEXT │
|
|
39
|
+
│ format_type │ TEXT │
|
|
40
|
+
│ filename │ TEXT │
|
|
41
|
+
│ status │ TEXT │
|
|
42
|
+
│ error │ TEXT NULL │
|
|
43
|
+
│ duration_sec │ REAL NULL │
|
|
44
|
+
│ file_size_bytes │ INTEGER NULL │
|
|
45
|
+
│ created_at │ DATETIME │
|
|
46
|
+
└──────────────────┴──────────────┘
|
|
47
|
+
"""
|
|
48
|
+
record = {
|
|
49
|
+
'url': url,
|
|
50
|
+
'platform': platform,
|
|
51
|
+
'format_type': format_type,
|
|
52
|
+
'filename': filename,
|
|
53
|
+
'status': status, # 'success' | 'failed' | 'paused' | 'cancelled'
|
|
54
|
+
'error': error,
|
|
55
|
+
'duration_sec': duration_sec,
|
|
56
|
+
'file_size_bytes': file_size_bytes,
|
|
57
|
+
'created_at': datetime.now().isoformat(),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# TODO: db.insert('downloads', record)
|
|
61
|
+
log_event(f"[DB_RECORD] {record}")
|
|
62
|
+
return record
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ─────────────────────────────────────────────
|
|
66
|
+
# Download state manager — pause / stop / cancel
|
|
67
|
+
# ─────────────────────────────────────────────
|
|
68
|
+
class DownloadController:
|
|
69
|
+
"""
|
|
70
|
+
Thread-safe controller for pause / resume / cancel.
|
|
71
|
+
|
|
72
|
+
States:
|
|
73
|
+
running → download in progress
|
|
74
|
+
paused → download suspended (S key)
|
|
75
|
+
cancelled → user chose to terminate (C key)
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self):
|
|
79
|
+
self._state = 'running'
|
|
80
|
+
self._lock = threading.Lock()
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def is_paused(self):
|
|
84
|
+
with self._lock:
|
|
85
|
+
return self._state == 'paused'
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def is_cancelled(self):
|
|
89
|
+
with self._lock:
|
|
90
|
+
return self._state == 'cancelled'
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_running(self):
|
|
94
|
+
with self._lock:
|
|
95
|
+
return self._state == 'running'
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def state(self):
|
|
99
|
+
with self._lock:
|
|
100
|
+
return self._state
|
|
101
|
+
|
|
102
|
+
def pause(self):
|
|
103
|
+
with self._lock:
|
|
104
|
+
if self._state == 'running':
|
|
105
|
+
self._state = 'paused'
|
|
106
|
+
|
|
107
|
+
def resume(self):
|
|
108
|
+
with self._lock:
|
|
109
|
+
if self._state == 'paused':
|
|
110
|
+
self._state = 'running'
|
|
111
|
+
|
|
112
|
+
def cancel(self):
|
|
113
|
+
with self._lock:
|
|
114
|
+
self._state = 'cancelled'
|
|
115
|
+
|
|
116
|
+
def toggle_pause(self):
|
|
117
|
+
with self._lock:
|
|
118
|
+
if self._state == 'running':
|
|
119
|
+
self._state = 'paused'
|
|
120
|
+
elif self._state == 'paused':
|
|
121
|
+
self._state = 'running'
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ─────────────────────────────────────────────
|
|
125
|
+
# Keyboard listener thread (cross-platform)
|
|
126
|
+
# ─────────────────────────────────────────────
|
|
127
|
+
def _start_key_listener(controller: DownloadController):
|
|
128
|
+
"""
|
|
129
|
+
Listens for keypresses in a background thread:
|
|
130
|
+
S / s → pause / resume
|
|
131
|
+
C / c → cancel
|
|
132
|
+
Works on Windows (msvcrt) and Unix (tty/termios).
|
|
133
|
+
"""
|
|
134
|
+
def _listen():
|
|
135
|
+
if sys.platform == 'win32':
|
|
136
|
+
import msvcrt
|
|
137
|
+
while not controller.is_cancelled:
|
|
138
|
+
if msvcrt.kbhit():
|
|
139
|
+
key = msvcrt.getwch().lower()
|
|
140
|
+
if key == 's':
|
|
141
|
+
controller.toggle_pause()
|
|
142
|
+
elif key == 'c':
|
|
143
|
+
controller.cancel()
|
|
144
|
+
break
|
|
145
|
+
time.sleep(0.05)
|
|
146
|
+
else:
|
|
147
|
+
import tty
|
|
148
|
+
import termios
|
|
149
|
+
fd = sys.stdin.fileno()
|
|
150
|
+
old = termios.tcgetattr(fd)
|
|
151
|
+
try:
|
|
152
|
+
tty.setraw(fd)
|
|
153
|
+
while not controller.is_cancelled:
|
|
154
|
+
ch = sys.stdin.read(1).lower()
|
|
155
|
+
if ch == 's':
|
|
156
|
+
controller.toggle_pause()
|
|
157
|
+
elif ch == 'c':
|
|
158
|
+
controller.cancel()
|
|
159
|
+
break
|
|
160
|
+
finally:
|
|
161
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
162
|
+
|
|
163
|
+
t = threading.Thread(target=_listen, daemon=True)
|
|
164
|
+
t.start()
|
|
165
|
+
return t
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ─────────────────────────────────────────────
|
|
169
|
+
# Progress bar with pause/cancel controls
|
|
170
|
+
# ─────────────────────────────────────────────
|
|
171
|
+
class ProgressBar:
|
|
172
|
+
"""
|
|
173
|
+
Clean terminal progress bar.
|
|
174
|
+
Shows controls: [S] Stop/Resume [C] Cancel
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
BAR_WIDTH = 34
|
|
178
|
+
|
|
179
|
+
def __init__(self, controller: DownloadController):
|
|
180
|
+
self.controller = controller
|
|
181
|
+
self.current_item = 0
|
|
182
|
+
self.total_items = 1
|
|
183
|
+
self._last_line = ''
|
|
184
|
+
|
|
185
|
+
def hook(self, d):
|
|
186
|
+
while self.controller.is_paused:
|
|
187
|
+
self._render(paused=True)
|
|
188
|
+
time.sleep(0.3)
|
|
189
|
+
|
|
190
|
+
if self.controller.is_cancelled:
|
|
191
|
+
raise yt_dlp.utils.DownloadCancelled()
|
|
192
|
+
|
|
193
|
+
if d['status'] == 'downloading':
|
|
194
|
+
raw = d.get('_percent_str', '0%').strip()
|
|
195
|
+
raw = ''.join(c for c in raw if c.isdigit() or c in '.%')
|
|
196
|
+
try:
|
|
197
|
+
percent = float(raw.replace('%', ''))
|
|
198
|
+
except ValueError:
|
|
199
|
+
percent = 0.0
|
|
200
|
+
|
|
201
|
+
speed = d.get('_speed_str', '').strip() or '?'
|
|
202
|
+
eta = d.get('_eta_str', '').strip() or '?'
|
|
203
|
+
size = d.get('_total_bytes_str',
|
|
204
|
+
d.get('_total_bytes_estimate_str', '?')).strip()
|
|
205
|
+
|
|
206
|
+
self._render(percent=percent, speed=speed, eta=eta, size=size)
|
|
207
|
+
|
|
208
|
+
elif d['status'] == 'finished':
|
|
209
|
+
print()
|
|
210
|
+
|
|
211
|
+
def _render(self, percent=0.0, speed='', eta='', size='', paused=False):
|
|
212
|
+
filled = int(self.BAR_WIDTH * percent / 100)
|
|
213
|
+
bar = '█' * filled + '░' * (self.BAR_WIDTH - filled)
|
|
214
|
+
|
|
215
|
+
item_info = (
|
|
216
|
+
f" [{self.current_item}/{self.total_items}]"
|
|
217
|
+
if self.total_items > 1 else ""
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if paused:
|
|
221
|
+
status_icon = '⏸ PAUSED '
|
|
222
|
+
right = ' [S] Resume [C] Cancel'
|
|
223
|
+
else:
|
|
224
|
+
status_icon = '▶ '
|
|
225
|
+
right = ' [S] Stop [C] Cancel'
|
|
226
|
+
|
|
227
|
+
line = (
|
|
228
|
+
f"\r {status_icon}{bar} {percent:5.1f}%"
|
|
229
|
+
f" {size} {speed} ETA {eta}{item_info}{right} "
|
|
230
|
+
)
|
|
231
|
+
print(line, end='', flush=True)
|
|
232
|
+
self._last_line = line
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def show_format_menu(lang='en'):
|
|
236
|
+
"""Show format selection menu"""
|
|
237
|
+
if lang == 'az':
|
|
238
|
+
choices = [
|
|
239
|
+
'1️⃣ MP4 (Video+Audio) - Səs və video ilə indir',
|
|
240
|
+
'2️⃣ MP3 (Səs) - Yalnız səs indir',
|
|
241
|
+
'3️⃣ Silent Video - Yalnız video (səssiz) indir',
|
|
242
|
+
]
|
|
243
|
+
elif lang == 'tr':
|
|
244
|
+
choices = [
|
|
245
|
+
'1️⃣ MP4 (Video+Ses) - Ses ve video ile indir',
|
|
246
|
+
'2️⃣ MP3 (Ses) - Sadece ses indir',
|
|
247
|
+
'3️⃣ Sessiz Video - Sadece video indir (ses yok)',
|
|
248
|
+
]
|
|
249
|
+
else:
|
|
250
|
+
choices = [
|
|
251
|
+
'1️⃣ MP4 (Video+Audio) - Download with audio and video',
|
|
252
|
+
'2️⃣ MP3 (Audio) - Download audio only',
|
|
253
|
+
'3️⃣ Silent Video - Download video only (no audio)',
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
answer = select(
|
|
257
|
+
get_text('select_format', lang),
|
|
258
|
+
choices=choices,
|
|
259
|
+
use_arrow_keys=True
|
|
260
|
+
).ask()
|
|
261
|
+
|
|
262
|
+
if answer is None:
|
|
263
|
+
return 'mp4'
|
|
264
|
+
if '1️⃣' in answer:
|
|
265
|
+
return 'mp4'
|
|
266
|
+
elif '2️⃣' in answer:
|
|
267
|
+
return 'mp3'
|
|
268
|
+
else:
|
|
269
|
+
return 'silent'
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def download_media(url: str, format_type: str, lang: str = 'en') -> bool:
|
|
273
|
+
"""Download media from URL with pause/resume/cancel support."""
|
|
274
|
+
|
|
275
|
+
if not validate_url(url):
|
|
276
|
+
msgs = {'az': '❌ Düzgün olmayan URL formatı!',
|
|
277
|
+
'tr': '❌ Geçersiz URL formatı!'}
|
|
278
|
+
print(f"\n{msgs.get(lang, '❌ Invalid URL format!')}")
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
platform = get_platform_from_url(url)
|
|
282
|
+
if not platform:
|
|
283
|
+
msgs = {'az': '❌ Dəstəkləməyən platforma!',
|
|
284
|
+
'tr': '❌ Desteklenmeyen platform!'}
|
|
285
|
+
print(f"\n{msgs.get(lang, '❌ Unsupported platform!')}")
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
print(f"\n Platform : {platform}")
|
|
289
|
+
print(f" Format : {format_type.upper()}")
|
|
290
|
+
print(f" {get_text('downloading', lang)}")
|
|
291
|
+
print(f" Controls : [S] Stop/Resume [C] Cancel\n")
|
|
292
|
+
|
|
293
|
+
os.makedirs('downloads', exist_ok=True)
|
|
294
|
+
|
|
295
|
+
controller = DownloadController()
|
|
296
|
+
progress = ProgressBar(controller)
|
|
297
|
+
saved_files = []
|
|
298
|
+
start_time = time.time()
|
|
299
|
+
|
|
300
|
+
_start_key_listener(controller)
|
|
301
|
+
|
|
302
|
+
ydl_opts = {
|
|
303
|
+
'outtmpl': 'downloads/%(title)s.%(ext)s',
|
|
304
|
+
'quiet': True,
|
|
305
|
+
'no_warnings': True,
|
|
306
|
+
'noprogress': True,
|
|
307
|
+
'progress_hooks': [progress.hook],
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if format_type == 'mp4':
|
|
311
|
+
ydl_opts['format'] = 'best[ext=mp4]/best'
|
|
312
|
+
elif format_type == 'mp3':
|
|
313
|
+
ydl_opts['format'] = 'bestaudio/best'
|
|
314
|
+
ydl_opts['postprocessors'] = [{
|
|
315
|
+
'key': 'FFmpegExtractAudio',
|
|
316
|
+
'preferredcodec': 'mp3',
|
|
317
|
+
'preferredquality': '192',
|
|
318
|
+
}]
|
|
319
|
+
elif format_type == 'silent':
|
|
320
|
+
ydl_opts['format'] = 'bestvideo[ext=mp4]/bestvideo'
|
|
321
|
+
|
|
322
|
+
info_opts = dict(ydl_opts)
|
|
323
|
+
info_opts['quiet'] = True
|
|
324
|
+
info_opts['no_warnings'] = True
|
|
325
|
+
info_opts['extract_flat'] = 'in_playlist'
|
|
326
|
+
info_opts['progress_hooks'] = []
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
with yt_dlp.YoutubeDL(info_opts) as ydl_info:
|
|
330
|
+
meta = ydl_info.extract_info(url, download=False)
|
|
331
|
+
if meta and 'entries' in meta:
|
|
332
|
+
entries = [e for e in meta['entries'] if e]
|
|
333
|
+
progress.total_items = len(entries)
|
|
334
|
+
print(f" Playlist : {meta.get('title', '')} ({progress.total_items} items)\n")
|
|
335
|
+
else:
|
|
336
|
+
progress.total_items = 1
|
|
337
|
+
|
|
338
|
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
339
|
+
info = ydl.extract_info(url, download=True)
|
|
340
|
+
|
|
341
|
+
if 'entries' in info:
|
|
342
|
+
for entry in info['entries']:
|
|
343
|
+
if entry:
|
|
344
|
+
saved_files.append(ydl.prepare_filename(entry))
|
|
345
|
+
else:
|
|
346
|
+
saved_files.append(ydl.prepare_filename(info))
|
|
347
|
+
|
|
348
|
+
duration = round(time.time() - start_time, 2)
|
|
349
|
+
download_dir = os.path.abspath('downloads')
|
|
350
|
+
|
|
351
|
+
print(f"\n ✅ {get_text('download_complete', lang)}")
|
|
352
|
+
print(f" 📂 {download_dir}")
|
|
353
|
+
if len(saved_files) == 1:
|
|
354
|
+
fname = saved_files[0]
|
|
355
|
+
fsize = os.path.getsize(fname) if os.path.exists(fname) else None
|
|
356
|
+
print(f" 📄 {os.path.basename(fname)}")
|
|
357
|
+
if fsize:
|
|
358
|
+
print(f" 💾 {fsize / (1024*1024):.2f} MB ⏱ {duration}s")
|
|
359
|
+
create_download_record(url, platform, format_type, fname,
|
|
360
|
+
'success', duration_sec=duration,
|
|
361
|
+
file_size_bytes=fsize)
|
|
362
|
+
else:
|
|
363
|
+
print(f" 📄 {len(saved_files)} files saved ⏱ {duration}s")
|
|
364
|
+
for f in saved_files:
|
|
365
|
+
fsize = os.path.getsize(f) if os.path.exists(f) else None
|
|
366
|
+
create_download_record(url, platform, format_type, f,
|
|
367
|
+
'success', duration_sec=duration,
|
|
368
|
+
file_size_bytes=fsize)
|
|
369
|
+
|
|
370
|
+
log_event(f"Download completed: {len(saved_files)} file(s) from {platform} in {duration}s")
|
|
371
|
+
return True
|
|
372
|
+
|
|
373
|
+
except (yt_dlp.utils.DownloadCancelled, KeyboardInterrupt):
|
|
374
|
+
controller.cancel()
|
|
375
|
+
print(f"\n\n 🚫 {get_text('download_cancelled', lang)}")
|
|
376
|
+
create_download_record(url, platform, format_type, '', 'cancelled')
|
|
377
|
+
log_event(f"Download cancelled: {url}")
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
except Exception as e:
|
|
381
|
+
print(f"\n ❌ {get_text('error_download', lang)}: {str(e)}")
|
|
382
|
+
create_download_record(url, platform, format_type, '', 'failed', error=str(e))
|
|
383
|
+
log_event(f"Download error: {str(e)}", 'ERROR')
|
|
384
|
+
return False
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def start_download(lang: str = 'en') -> None:
|
|
388
|
+
"""Start download process"""
|
|
389
|
+
os.system('cls' if os.name == 'nt' else 'clear')
|
|
390
|
+
|
|
391
|
+
print("\n" + "=" * 50)
|
|
392
|
+
print(f" ▶️ {get_text('welcome', lang)}")
|
|
393
|
+
print("=" * 50 + "\n")
|
|
394
|
+
|
|
395
|
+
url = questionary_text(
|
|
396
|
+
get_text('enter_url', lang),
|
|
397
|
+
validate=lambda x: len(x.strip()) > 5 or get_text('error_invalid_url', lang)
|
|
398
|
+
).ask()
|
|
399
|
+
|
|
400
|
+
if not url or not url.strip():
|
|
401
|
+
print(f"\n ❌ {get_text('error_no_url', lang)}")
|
|
402
|
+
try:
|
|
403
|
+
input(f"\n{get_text('press_enter', lang)}")
|
|
404
|
+
except (EOFError, KeyboardInterrupt):
|
|
405
|
+
pass
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
url = url.strip()
|
|
409
|
+
format_type = show_format_menu(lang)
|
|
410
|
+
download_media(url, format_type, lang)
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
input(f"\n{get_text('press_enter', lang)}")
|
|
414
|
+
except (EOFError, KeyboardInterrupt):
|
|
415
|
+
pass
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SaveHub CLI - Interactive Menu Module
|
|
3
|
+
Supports: Azərbaycanca, English, Türkçe
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from questionary import select
|
|
8
|
+
from savehub_cli.translations import get_text
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def clear_screen():
|
|
12
|
+
"""Clear terminal screen"""
|
|
13
|
+
os.system('cls' if os.name == 'nt' else 'clear')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def display_header(lang='en'):
|
|
17
|
+
"""Display ASCII art header"""
|
|
18
|
+
header = """
|
|
19
|
+
███████╗ █████╗ ██╗ ██╗███████╗██╗ ██╗██╗ ██╗██████╗
|
|
20
|
+
██╔════╝██╔══██╗██║ ██║██╔════╝██║ ██║██║ ██║██╔══██╗
|
|
21
|
+
███████╗███████║██║ ██║█████╗ ███████║██║ ██║██████╔╝
|
|
22
|
+
╚════██╗██╔══██║╚██╗ ██╔╝██╔══╝ ██╔══██║██║ ██║██╔══██╗
|
|
23
|
+
███████║██║ ██║ ╚████╔╝ ███████╗██║ ██║╚██████╔╝██████╔╝
|
|
24
|
+
╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝
|
|
25
|
+
"""
|
|
26
|
+
print(header)
|
|
27
|
+
print("[Version 1.0.0] - Made with ❤️ by Sadiq Musayev\n")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def select_language():
|
|
31
|
+
"""Language selection menu"""
|
|
32
|
+
print("\n" + "=" * 50)
|
|
33
|
+
print("🌍 SELECT YOUR LANGUAGE / DİLİNİZİ SEÇİN / DİLİ SEÇİN")
|
|
34
|
+
print("=" * 50 + "\n")
|
|
35
|
+
|
|
36
|
+
choices = [
|
|
37
|
+
'🇦🇿 Azərbaycanca',
|
|
38
|
+
'🇬🇧 English',
|
|
39
|
+
'🇹🇷 Türkçe'
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
answer = select(
|
|
43
|
+
'Language / Dil / Dil:',
|
|
44
|
+
choices=choices,
|
|
45
|
+
use_arrow_keys=True,
|
|
46
|
+
use_indicator=True
|
|
47
|
+
).ask()
|
|
48
|
+
|
|
49
|
+
if 'Azərbaycanca' in answer:
|
|
50
|
+
return 'az'
|
|
51
|
+
elif 'English' in answer:
|
|
52
|
+
return 'en'
|
|
53
|
+
else:
|
|
54
|
+
return 'tr'
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_OPTION_KEYS = ['option_docs', 'option_terms', 'option_feedback', 'option_start', 'option_exit']
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def show_main_menu(lang='en'):
|
|
61
|
+
"""
|
|
62
|
+
Main menu display.
|
|
63
|
+
Returns the internal option key string (e.g. 'option_start')
|
|
64
|
+
so that main.py can match on it reliably — regardless of language.
|
|
65
|
+
"""
|
|
66
|
+
label_to_key = {get_text(key, lang): key for key in _OPTION_KEYS}
|
|
67
|
+
|
|
68
|
+
answer = select(
|
|
69
|
+
get_text('main_menu_question', lang),
|
|
70
|
+
choices=list(label_to_key.keys()),
|
|
71
|
+
use_arrow_keys=True,
|
|
72
|
+
use_indicator=True
|
|
73
|
+
).ask()
|
|
74
|
+
|
|
75
|
+
return label_to_key.get(answer, answer)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def show_documentation(lang='en'):
|
|
79
|
+
"""Display documentation"""
|
|
80
|
+
if lang == 'az':
|
|
81
|
+
doc_text = """
|
|
82
|
+
╔════════════════════════════════════════════════════════════╗
|
|
83
|
+
║ 📚 SAVEHUB CLI - İSTİFADƏÇİ KƏLAVUZu ║
|
|
84
|
+
╚════════════════════════════════════════════════════════════╝
|
|
85
|
+
|
|
86
|
+
🎥 DƏSTƏKLƏNƏN PLATFORMALAR:
|
|
87
|
+
• YouTube - Videolar və playlist
|
|
88
|
+
• Instagram - Foto və videolar
|
|
89
|
+
• Facebook - Videolar
|
|
90
|
+
• X (Twitter) - Videolar və GIF
|
|
91
|
+
• LinkedIn - Videolar
|
|
92
|
+
• Snapchat - Videolar
|
|
93
|
+
|
|
94
|
+
📊 FORMAT SEÇİMLƏRİ:
|
|
95
|
+
• MP4 (Video+Audio) - Səs və video ilə indir
|
|
96
|
+
• MP3 (Səs) - Yalnız səs indir
|
|
97
|
+
• Silent Video - Yalnız video (səssiz) indir
|
|
98
|
+
|
|
99
|
+
⚙️ NECƏ İSTİFADƏ EDƏCƏKSINIZ:
|
|
100
|
+
1. "Endirmə aləti başlat" seçin
|
|
101
|
+
2. Media linkini daxil edin
|
|
102
|
+
3. Formatı seçin
|
|
103
|
+
4. Yükləmə avtomatik başlanacaq
|
|
104
|
+
5. Fayllar 'downloads' qovluğunda saxlanılır
|
|
105
|
+
|
|
106
|
+
🔒 XƏBƏRDARLIQLAR:
|
|
107
|
+
• Yalnız qanuni məqsədlər üçün istifadə edin
|
|
108
|
+
• Müəlliflik hüquqlarına hörmət edin
|
|
109
|
+
• Başqasının kontentini paylaşmayın
|
|
110
|
+
|
|
111
|
+
📁 LOGLAR:
|
|
112
|
+
• Bütün əməliyyatlar 'logs' qovluğunda saxlanılır
|
|
113
|
+
• Tarih və vaxt ilə adlandırılmış fayllar
|
|
114
|
+
"""
|
|
115
|
+
elif lang == 'tr':
|
|
116
|
+
doc_text = """
|
|
117
|
+
╔════════════════════════════════════════════════════════════╗
|
|
118
|
+
║ 📚 SAVEHUB CLI - KULLANICI KILAVUZu ║
|
|
119
|
+
╚════════════════════════════════════════════════════════════╝
|
|
120
|
+
|
|
121
|
+
🎥 DESTEKLENEN PLATFORMLAR:
|
|
122
|
+
• YouTube - Videolar ve oynatma listeleri
|
|
123
|
+
• Instagram - Fotoğraflar ve videolar
|
|
124
|
+
• Facebook - Videolar
|
|
125
|
+
• X (Twitter) - Videolar ve GIF'ler
|
|
126
|
+
• LinkedIn - Videolar
|
|
127
|
+
• Snapchat - Videolar
|
|
128
|
+
|
|
129
|
+
📊 FORMAT SEÇENEKLERİ:
|
|
130
|
+
• MP4 (Video+Ses) - Ses ve video ile indir
|
|
131
|
+
• MP3 (Ses) - Sadece ses indir
|
|
132
|
+
• Sessiz Video - Sadece video indir (ses yok)
|
|
133
|
+
|
|
134
|
+
⚙️ NASIL KULLANILIR:
|
|
135
|
+
1. "İndirme Aracını Başlat" seçin
|
|
136
|
+
2. Medya linkini girin
|
|
137
|
+
3. Formatı seçin
|
|
138
|
+
4. İndirme otomatik başlayacak
|
|
139
|
+
5. Dosyalar 'downloads' klasöründe kaydedilir
|
|
140
|
+
|
|
141
|
+
🔒 UYARILAR:
|
|
142
|
+
• Sadece yasal amaçlar için kullanın
|
|
143
|
+
• Telif haklarına saygı gösterin
|
|
144
|
+
• Başkasının içeriğini paylaşmayın
|
|
145
|
+
|
|
146
|
+
📁 KAYITLAR:
|
|
147
|
+
• Tüm işlemler 'logs' klasöründe kaydedilir
|
|
148
|
+
• Tarih ve saat ile adlandırılmış dosyalar
|
|
149
|
+
"""
|
|
150
|
+
else:
|
|
151
|
+
doc_text = """
|
|
152
|
+
╔════════════════════════════════════════════════════════════╗
|
|
153
|
+
║ 📚 SAVEHUB CLI - USER GUIDE ║
|
|
154
|
+
╚════════════════════════════════════════════════════════════╝
|
|
155
|
+
|
|
156
|
+
🎥 SUPPORTED PLATFORMS:
|
|
157
|
+
• YouTube - Videos and playlists
|
|
158
|
+
• Instagram - Photos and videos
|
|
159
|
+
• Facebook - Videos
|
|
160
|
+
• X (Twitter) - Videos and GIFs
|
|
161
|
+
• LinkedIn - Videos
|
|
162
|
+
• Snapchat - Videos
|
|
163
|
+
|
|
164
|
+
📊 DOWNLOAD FORMATS:
|
|
165
|
+
• MP4 (Video+Audio) - Download with audio and video
|
|
166
|
+
• MP3 (Audio) - Download audio only
|
|
167
|
+
• Silent Video - Download video only (no audio)
|
|
168
|
+
|
|
169
|
+
⚙️ HOW TO USE:
|
|
170
|
+
1. Select "Start Download Tool"
|
|
171
|
+
2. Enter media URL
|
|
172
|
+
3. Select format
|
|
173
|
+
4. Download will start automatically
|
|
174
|
+
5. Files will be saved in 'downloads' folder
|
|
175
|
+
|
|
176
|
+
🔒 WARNINGS:
|
|
177
|
+
• Use only for legal purposes
|
|
178
|
+
• Respect copyright rights
|
|
179
|
+
• Do not share others' content
|
|
180
|
+
|
|
181
|
+
📁 LOGS:
|
|
182
|
+
• All operations are saved in 'logs' folder
|
|
183
|
+
• Files are named with date and time
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
print(doc_text)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def show_terms(lang='en'):
|
|
190
|
+
"""Display terms of use"""
|
|
191
|
+
if lang == 'az':
|
|
192
|
+
terms_text = """
|
|
193
|
+
╔════════════════════════════════════════════════════════════╗
|
|
194
|
+
║ 📋 İSTİFADƏ ŞƏRTLƏRI ║
|
|
195
|
+
╚════════════════════════════════════════════════════════════╝
|
|
196
|
+
|
|
197
|
+
1. 🔒 HÜQUQI MƏSULIYYƏT
|
|
198
|
+
SaveHub CLI yalnız qanuni və şəxsi məqsədlər üçün
|
|
199
|
+
istifadə edilməlidir.
|
|
200
|
+
|
|
201
|
+
2. 📋 PLATFORM QANUNLARI
|
|
202
|
+
Hər platformanın öz şərtləri vardır. Platformaların
|
|
203
|
+
şərtlərinə riayət edin.
|
|
204
|
+
|
|
205
|
+
3. ⚖️ MƏSULIYYƏT REDDI
|
|
206
|
+
Müəlliflik hüquqları orijinal müəllifə aiddir.
|
|
207
|
+
SaveHub yaradıcı deyil, yardımçıdır.
|
|
208
|
+
|
|
209
|
+
4. 🚫 MƏHDUDIYYƏTLƏR
|
|
210
|
+
• Kütləvi endirmə qadağandır
|
|
211
|
+
• Kommersial məqsədlərlə istifadə qadağandır
|
|
212
|
+
• Başqasının kontentini paylaşmaq qadağandır
|
|
213
|
+
|
|
214
|
+
5. ✅ QƏBUL
|
|
215
|
+
Bu proqramı istifadə edərək şərtləri qəbul edirsiniz.
|
|
216
|
+
"""
|
|
217
|
+
elif lang == 'tr':
|
|
218
|
+
terms_text = """
|
|
219
|
+
╔════════════════════════════════════════════════════════════╗
|
|
220
|
+
║ 📋 KULLANIM ŞARTLARI ║
|
|
221
|
+
╚════════════════════════════════════════════════════════════╝
|
|
222
|
+
|
|
223
|
+
1. 🔒 YASAL SORUMLULUK
|
|
224
|
+
SaveHub CLI sadece yasal ve kişisel amaçlar için
|
|
225
|
+
kullanılmalıdır.
|
|
226
|
+
|
|
227
|
+
2. 📋 PLATFORM YASALARI
|
|
228
|
+
Her platformanın kendi şartları vardır. Platform
|
|
229
|
+
şartlarına uyun.
|
|
230
|
+
|
|
231
|
+
3. ⚖️ SORUMLULUK REDDI
|
|
232
|
+
Telif hakları orijinal sahibine aittir.
|
|
233
|
+
SaveHub yaratıcı değil, kolaylaştırıcıdır.
|
|
234
|
+
|
|
235
|
+
4. 🚫 KISITLAMALAR
|
|
236
|
+
• Toplu indirme yasaktır
|
|
237
|
+
• Ticari amaçla kullanım yasaktır
|
|
238
|
+
• Başkasının içeriğini paylaşmak yasaktır
|
|
239
|
+
|
|
240
|
+
5. ✅ KABUL
|
|
241
|
+
Bu programı kullanarak şartları kabul edersiniz.
|
|
242
|
+
"""
|
|
243
|
+
else:
|
|
244
|
+
terms_text = """
|
|
245
|
+
╔════════════════════════════════════════════════════════════╗
|
|
246
|
+
║ 📋 TERMS OF USE ║
|
|
247
|
+
╚════════════════════════════════════════════════════════════╝
|
|
248
|
+
|
|
249
|
+
1. 🔒 LEGAL RESPONSIBILITY
|
|
250
|
+
SaveHub CLI should only be used for legal and
|
|
251
|
+
personal purposes.
|
|
252
|
+
|
|
253
|
+
2. 📋 PLATFORM LAWS
|
|
254
|
+
Each platform has its own terms. Comply with
|
|
255
|
+
platform terms.
|
|
256
|
+
|
|
257
|
+
3. ⚖️ DISCLAIMER
|
|
258
|
+
Copyright rights belong to the original author.
|
|
259
|
+
SaveHub is a facilitator, not a creator.
|
|
260
|
+
|
|
261
|
+
4. 🚫 RESTRICTIONS
|
|
262
|
+
• Bulk downloading is prohibited
|
|
263
|
+
• Commercial use is prohibited
|
|
264
|
+
• Sharing others' content is prohibited
|
|
265
|
+
|
|
266
|
+
5. ✅ ACCEPTANCE
|
|
267
|
+
By using this program, you accept these terms.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
print(terms_text)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def show_feedback(lang='en'):
|
|
274
|
+
"""Display feedback information"""
|
|
275
|
+
if lang == 'az':
|
|
276
|
+
feedback_text = """
|
|
277
|
+
╔════════════════════════════════════════════════════════════╗
|
|
278
|
+
║ 💬 RƏY GÖNDƏR ║
|
|
279
|
+
╚════════════════════════════════════════════════════════════╝
|
|
280
|
+
|
|
281
|
+
Sizin fikirləriniz bizə çox maraqlıdır!
|
|
282
|
+
|
|
283
|
+
📧 ƏLAQƏ YOLLARI:
|
|
284
|
+
|
|
285
|
+
1️⃣ GitHub Issues
|
|
286
|
+
https://github.com/Sadiq-Musayev/SaveHub-CLI/issues
|
|
287
|
+
|
|
288
|
+
2️⃣ GitHub Repository
|
|
289
|
+
https://github.com/Sadiq-Musayev/SaveHub-CLI
|
|
290
|
+
|
|
291
|
+
💡 MÜŞTƏRƏK OLMAQ İÇƏN:
|
|
292
|
+
• Xətaları bildir
|
|
293
|
+
• Yeni xüsusiyyətlər təklif et
|
|
294
|
+
• Kodda dəyişikliklər göndər
|
|
295
|
+
• Sənədləşməni yaxşılaşdır
|
|
296
|
+
|
|
297
|
+
Sizin dəstəyiniz üçün təşəkkürlər! ❤️
|
|
298
|
+
"""
|
|
299
|
+
elif lang == 'tr':
|
|
300
|
+
feedback_text = """
|
|
301
|
+
╔════════════════════════════════════════════════════════════╗
|
|
302
|
+
║ 💬 GERİ BİLDİRİM GÖNDER ║
|
|
303
|
+
╚════════════════════════════════════════════════════════════╝
|
|
304
|
+
|
|
305
|
+
Görüşleriniz bize çok önemli!
|
|
306
|
+
|
|
307
|
+
📧 İLETİŞİM YOLLARI:
|
|
308
|
+
|
|
309
|
+
1️⃣ GitHub Issues
|
|
310
|
+
https://github.com/Sadiq-Musayev/SaveHub-CLI/issues
|
|
311
|
+
|
|
312
|
+
2️⃣ GitHub Repository
|
|
313
|
+
https://github.com/Sadiq-Musayev/SaveHub-CLI
|
|
314
|
+
|
|
315
|
+
🤝 KATKIDA BULUNMAK İÇİN:
|
|
316
|
+
• Hataları bildir
|
|
317
|
+
• Yeni özellikler öner
|
|
318
|
+
• Kodda değişiklikler gönder
|
|
319
|
+
• Belgeleri iyileştir
|
|
320
|
+
|
|
321
|
+
Desteğiniz için teşekkür ederiz! ❤️
|
|
322
|
+
"""
|
|
323
|
+
else:
|
|
324
|
+
feedback_text = """
|
|
325
|
+
╔════════════════════════════════════════════════════════════╗
|
|
326
|
+
║ 💬 SEND FEEDBACK ║
|
|
327
|
+
╚════════════════════════════════════════════════════════════╝
|
|
328
|
+
|
|
329
|
+
Your opinions are very important to us!
|
|
330
|
+
|
|
331
|
+
📧 CONTACT WAYS:
|
|
332
|
+
|
|
333
|
+
1️⃣ GitHub Issues
|
|
334
|
+
https://github.com/Sadiq-Musayev/SaveHub-CLI/issues
|
|
335
|
+
|
|
336
|
+
2️⃣ GitHub Repository
|
|
337
|
+
https://github.com/Sadiq-Musayev/SaveHub-CLI
|
|
338
|
+
|
|
339
|
+
🤝 HOW TO CONTRIBUTE:
|
|
340
|
+
• Report bugs
|
|
341
|
+
• Suggest new features
|
|
342
|
+
• Submit code changes
|
|
343
|
+
• Improve documentation
|
|
344
|
+
|
|
345
|
+
Thank you for your support! ❤️
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
print(feedback_text)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SaveHub CLI - Utilities Module
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
logging.basicConfig(
|
|
11
|
+
level=logging.INFO,
|
|
12
|
+
format='%(asctime)s - %(levelname)s - %(message)s'
|
|
13
|
+
)
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
SUPPORTED_PLATFORMS = {
|
|
17
|
+
'YouTube': [r'(youtube\.com|youtu\.be)'],
|
|
18
|
+
'Instagram': [r'instagram\.com'],
|
|
19
|
+
'Facebook': [r'(facebook\.com|fb\.watch)'],
|
|
20
|
+
'Twitter/X': [r'(twitter\.com|x\.com)'],
|
|
21
|
+
'LinkedIn': [r'linkedin\.com'],
|
|
22
|
+
'Snapchat': [r'snapchat\.com'],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_url(url: str) -> bool:
|
|
27
|
+
"""Validates whether the given string is a properly formatted URL."""
|
|
28
|
+
if not url or not isinstance(url, str):
|
|
29
|
+
return False
|
|
30
|
+
regex = re.compile(
|
|
31
|
+
r'^https?://'
|
|
32
|
+
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|'
|
|
33
|
+
r'localhost|'
|
|
34
|
+
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
|
|
35
|
+
r'(?::\d+)?'
|
|
36
|
+
r'(?:/?|[/?]\S+)$',
|
|
37
|
+
re.IGNORECASE
|
|
38
|
+
)
|
|
39
|
+
return re.match(regex, url) is not None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_valid_url(url: str) -> bool:
|
|
43
|
+
"""Alias for validate_url (backward compatibility)."""
|
|
44
|
+
return validate_url(url)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_platform_from_url(url: str) -> str | None:
|
|
48
|
+
"""Detects which supported platform a URL belongs to."""
|
|
49
|
+
if not url:
|
|
50
|
+
return None
|
|
51
|
+
for platform, patterns in SUPPORTED_PLATFORMS.items():
|
|
52
|
+
for pattern in patterns:
|
|
53
|
+
if re.search(pattern, url, re.IGNORECASE):
|
|
54
|
+
return platform
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def log_event(message: str, level: str = 'INFO') -> None:
|
|
59
|
+
"""Logs an event to console and to a dated log file."""
|
|
60
|
+
level = level.upper()
|
|
61
|
+
if level == 'ERROR':
|
|
62
|
+
logger.error(message)
|
|
63
|
+
elif level == 'WARNING':
|
|
64
|
+
logger.warning(message)
|
|
65
|
+
else:
|
|
66
|
+
logger.info(message)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
Path('logs').mkdir(exist_ok=True)
|
|
70
|
+
log_filename = f"logs/{datetime.now().strftime('%Y-%m-%d')}.log"
|
|
71
|
+
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
72
|
+
with open(log_filename, 'a', encoding='utf-8') as f:
|
|
73
|
+
f.write(f"[{timestamp}] [{level}] {message}\n")
|
|
74
|
+
except OSError as e:
|
|
75
|
+
logger.warning(f"Could not write to log file: {e}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def log_message(message: str) -> None:
|
|
79
|
+
"""Alias for log_event (backward compatibility)."""
|
|
80
|
+
log_event(message)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def helper_function():
|
|
84
|
+
"""Placeholder helper function for future use."""
|
|
85
|
+
pass
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SaveHub CLI - Translations Module
|
|
3
|
+
Supports: Azərbaycanca (AZ), English (EN), Türkçe (TR)
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
TRANSLATIONS = {
|
|
7
|
+
'hello': {
|
|
8
|
+
'en': 'Hello',
|
|
9
|
+
'az': 'Salam',
|
|
10
|
+
'tr': 'Merhaba'
|
|
11
|
+
},
|
|
12
|
+
'goodbye': {
|
|
13
|
+
'en': 'Goodbye',
|
|
14
|
+
'az': 'Sağol',
|
|
15
|
+
'tr': 'Hoşça kal'
|
|
16
|
+
},
|
|
17
|
+
'thank_you': {
|
|
18
|
+
'en': 'Thank you',
|
|
19
|
+
'az': 'Təşəkkür edirəm',
|
|
20
|
+
'tr': 'Teşekkür ederim'
|
|
21
|
+
},
|
|
22
|
+
'press_enter': {
|
|
23
|
+
'en': 'Press Enter to continue...',
|
|
24
|
+
'az': 'Davam etmək üçün Enter basın...',
|
|
25
|
+
'tr': 'Devam etmek için Enter\'a basın...'
|
|
26
|
+
},
|
|
27
|
+
'see_you': {
|
|
28
|
+
'en': 'See you next time!',
|
|
29
|
+
'az': 'Görüşənədək!',
|
|
30
|
+
'tr': 'Görüşürüz!'
|
|
31
|
+
},
|
|
32
|
+
'main_menu_question': {
|
|
33
|
+
'en': 'What would you like to do?',
|
|
34
|
+
'az': 'Nə etmək istəyirsiniz?',
|
|
35
|
+
'tr': 'Ne yapmak istersiniz?'
|
|
36
|
+
},
|
|
37
|
+
'option_docs': {
|
|
38
|
+
'en': '📚 Documentation',
|
|
39
|
+
'az': '📚 Sənədləşmə',
|
|
40
|
+
'tr': '📚 Belgeler'
|
|
41
|
+
},
|
|
42
|
+
'option_terms': {
|
|
43
|
+
'en': '📋 Terms of Use',
|
|
44
|
+
'az': '📋 İstifadə Şərtləri',
|
|
45
|
+
'tr': '📋 Kullanım Şartları'
|
|
46
|
+
},
|
|
47
|
+
'option_feedback': {
|
|
48
|
+
'en': '💬 Send Feedback',
|
|
49
|
+
'az': '💬 Rəy Göndər',
|
|
50
|
+
'tr': '💬 Geri Bildirim Gönder'
|
|
51
|
+
},
|
|
52
|
+
'option_start': {
|
|
53
|
+
'en': '▶️ Start Download Tool',
|
|
54
|
+
'az': '▶️ Endirmə Alətini Başlat',
|
|
55
|
+
'tr': '▶️ İndirme Aracını Başlat'
|
|
56
|
+
},
|
|
57
|
+
'option_exit': {
|
|
58
|
+
'en': '🚪 Exit',
|
|
59
|
+
'az': '🚪 Çıxış',
|
|
60
|
+
'tr': '🚪 Çıkış'
|
|
61
|
+
},
|
|
62
|
+
'welcome': {
|
|
63
|
+
'en': 'SaveHub Download Tool',
|
|
64
|
+
'az': 'SaveHub Endirmə Aləti',
|
|
65
|
+
'tr': 'SaveHub İndirme Aracı'
|
|
66
|
+
},
|
|
67
|
+
'enter_url': {
|
|
68
|
+
'en': 'Enter media URL:',
|
|
69
|
+
'az': 'Media linkini daxil edin:',
|
|
70
|
+
'tr': 'Medya linkini girin:'
|
|
71
|
+
},
|
|
72
|
+
'select_format': {
|
|
73
|
+
'en': 'Select download format:',
|
|
74
|
+
'az': 'Endirmə formatını seçin:',
|
|
75
|
+
'tr': 'İndirme formatını seçin:'
|
|
76
|
+
},
|
|
77
|
+
'downloading': {
|
|
78
|
+
'en': 'Downloading, please wait...',
|
|
79
|
+
'az': 'Yüklənir, zəhmət olmasa gözləyin...',
|
|
80
|
+
'tr': 'İndiriliyor, lütfen bekleyin...'
|
|
81
|
+
},
|
|
82
|
+
'download_complete': {
|
|
83
|
+
'en': 'Download complete!',
|
|
84
|
+
'az': 'Yükləmə tamamlandı!',
|
|
85
|
+
'tr': 'İndirme tamamlandı!'
|
|
86
|
+
},
|
|
87
|
+
'error_download': {
|
|
88
|
+
'en': 'Download error',
|
|
89
|
+
'az': 'Endirmə xətası',
|
|
90
|
+
'tr': 'İndirme hatası'
|
|
91
|
+
},
|
|
92
|
+
'error_invalid_url': {
|
|
93
|
+
'en': 'Please enter a valid URL',
|
|
94
|
+
'az': 'Zəhmət olmasa düzgün URL daxil edin',
|
|
95
|
+
'tr': 'Lütfen geçerli bir URL girin'
|
|
96
|
+
},
|
|
97
|
+
'error_no_url': {
|
|
98
|
+
'en': 'No URL entered!',
|
|
99
|
+
'az': 'URL daxil edilmədi!',
|
|
100
|
+
'tr': 'URL girilmedi!'
|
|
101
|
+
},
|
|
102
|
+
'download_cancelled': {
|
|
103
|
+
'en': 'Download cancelled.',
|
|
104
|
+
'az': 'Yükləmə dayandırıldı.',
|
|
105
|
+
'tr': 'İndirme iptal edildi.'
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_text(key: str, lang: str = 'en') -> str:
|
|
111
|
+
"""
|
|
112
|
+
Get translated text for a given key and language.
|
|
113
|
+
Falls back to English if language not found,
|
|
114
|
+
then to the key itself if completely missing.
|
|
115
|
+
"""
|
|
116
|
+
entry = TRANSLATIONS.get(key)
|
|
117
|
+
if entry is None:
|
|
118
|
+
return key
|
|
119
|
+
return entry.get(lang) or entry.get('en') or key
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: savehub-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Multilingual CLI tool to download videos, audio and images from social media platforms
|
|
5
|
+
Author: Sadiq Musayev
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Sadiq-Musayev/SaveHub-CLI
|
|
8
|
+
Project-URL: Repository, https://github.com/Sadiq-Musayev/SaveHub-CLI
|
|
9
|
+
Project-URL: Issues, https://github.com/Sadiq-Musayev/SaveHub-CLI/issues
|
|
10
|
+
Keywords: youtube,instagram,downloader,cli,mp3,mp4,social-media
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
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 :: Utilities
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: yt-dlp>=2024.1.0
|
|
26
|
+
Requires-Dist: questionary>=2.0.0
|
|
27
|
+
Requires-Dist: colorama>=0.4.6
|
|
28
|
+
Requires-Dist: requests>=2.31.0
|
|
29
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# SaveHub CLI
|
|
33
|
+
|
|
34
|
+
**Multilingual social media downloader — MP4 · MP3 · Silent Video**
|
|
35
|
+
|
|
36
|
+
> Supports: 🇦🇿 Azərbaycanca · 🇬🇧 English · 🇹🇷 Türkçe
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install savehub-cli
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
> **Requires Python 3.10+** and [FFmpeg](https://ffmpeg.org/download.html) (for MP3 conversion).
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
savehub-cli
|
|
54
|
+
# or
|
|
55
|
+
savehub
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
1. Select your language
|
|
59
|
+
2. Choose **Start Download Tool**
|
|
60
|
+
3. Paste any supported URL
|
|
61
|
+
4. Select format: **MP4 · MP3 · Silent Video**
|
|
62
|
+
5. Files are saved to the `downloads/` folder
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Supported Platforms
|
|
67
|
+
|
|
68
|
+
| Platform | Video | Audio | Photo |
|
|
69
|
+
|-------------|:-----:|:-----:|:-----:|
|
|
70
|
+
| YouTube | ✅ | ✅ | — |
|
|
71
|
+
| Instagram | ✅ | ✅ | ✅ |
|
|
72
|
+
| Facebook | ✅ | — | — |
|
|
73
|
+
| X (Twitter) | ✅ | — | — |
|
|
74
|
+
| LinkedIn | ✅ | — | — |
|
|
75
|
+
| Snapchat | ✅ | — | — |
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Download Formats
|
|
80
|
+
|
|
81
|
+
| Format | Description |
|
|
82
|
+
|--------------|------------------------------|
|
|
83
|
+
| MP4 | Video + Audio (best quality) |
|
|
84
|
+
| MP3 | Audio only (192kbps) |
|
|
85
|
+
| Silent Video | Video only, no audio |
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Controls During Download
|
|
90
|
+
|
|
91
|
+
| Key | Action |
|
|
92
|
+
|-----|----------------|
|
|
93
|
+
| `S` | Pause / Resume |
|
|
94
|
+
| `C` | Cancel |
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Requirements
|
|
99
|
+
|
|
100
|
+
- Python ≥ 3.10
|
|
101
|
+
- FFmpeg (for MP3 export) — [Download here](https://ffmpeg.org/download.html)
|
|
102
|
+
- Dependencies: `yt-dlp`, `questionary`, `colorama`, `requests`, `python-dotenv`
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Logs
|
|
107
|
+
|
|
108
|
+
All download events are saved in `logs/YYYY-MM-DD.log`.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Contributing
|
|
113
|
+
|
|
114
|
+
1. Fork the repo
|
|
115
|
+
2. Create your branch: `git checkout -b feature/my-feature`
|
|
116
|
+
3. Commit: `git commit -m "Add my feature"`
|
|
117
|
+
4. Push: `git push origin feature/my-feature`
|
|
118
|
+
5. Open a Pull Request
|
|
119
|
+
|
|
120
|
+
Bug reports and feature requests → [GitHub Issues](https://github.com/Sadiq-Musayev/SaveHub-CLI/issues)
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
[MIT](LICENSE) © 2025 Sadiq Musayev
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
savehub_cli/__init__.py,sha256=RS7zal1RgLE87V9s2XmAO5R-KMln9MF_YcMma6EKgKs,195
|
|
2
|
+
savehub_cli/config.py,sha256=Q1qw0oHC-VUS9X3OH8nhzyiXSAgKb0_dHxzKfFE1Rts,950
|
|
3
|
+
savehub_cli/main.py,sha256=SgER40JruwoVe-8yJ-s86bn8D1fpBtnSCyofEOESUsc,2485
|
|
4
|
+
savehub_cli/translations.py,sha256=KjJLv_vX-2-0XZ3kY4X28dzGT4nLMsMLgKfQ855wlJ4,3469
|
|
5
|
+
savehub_cli/modules/__init__.py,sha256=OCgLPqYyE0OFm2IKDQUMZ7JIxAAbOI6uWk-fb9czKJQ,208
|
|
6
|
+
savehub_cli/modules/downloader.py,sha256=iSI9oEdOY5IwT-vtDH_ink3XMy3FGJu6mr4nZIpxRdg,15079
|
|
7
|
+
savehub_cli/modules/menu.py,sha256=c9dmfYHUdTPw3t-TWCpbYgvjfIHCPp8zdIb1FCsLDuk,14113
|
|
8
|
+
savehub_cli/modules/utils.py,sha256=OvNS1omdtOJH4pBs3Xm7Etn4hYnZ5h4dMX5PkFiJ7Vg,2494
|
|
9
|
+
savehub_cli-1.0.0.dist-info/licenses/LICENSE,sha256=CpCEkJEQYbnyJs8h4fBHS6n-OIR4HoIthkpZ9CZqqrM,1070
|
|
10
|
+
savehub_cli-1.0.0.dist-info/METADATA,sha256=JWYhKPoYUo1EEQvii436ucP62Pub7sQ9JMiGBxzdCHo,3351
|
|
11
|
+
savehub_cli-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
savehub_cli-1.0.0.dist-info/entry_points.txt,sha256=TuiuWSzWYtMEHhZhGfsE4LCEIFIhhUae_q3em2urcCQ,86
|
|
13
|
+
savehub_cli-1.0.0.dist-info/top_level.txt,sha256=quzfCkswKTGU8QYhcprTD6ra2a61FdUd38JwaCYfcDk,12
|
|
14
|
+
savehub_cli-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Sadiq Musayev
|
|
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
|
+
savehub_cli
|