sh-tiktok-studio 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: sh-tiktok-studio
3
+ Version: 2.0.0
4
+ Summary: TikTok downloader & search tool with key-based access control, quality selection, and search history
5
+ Requires-Python: >=3.8
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: requests
8
+ Requires-Dist: rich
9
+ Requires-Dist: pyfiglet
10
+ Requires-Dist: pymongo
11
+ Requires-Dist: dnspython
12
+
13
+ # SH TikTok Studio
14
+
15
+ TikTok downloader & search tool — no-watermark quality selection, paginated
16
+ search, batch download, and search history.
17
+
18
+ ## Install
19
+
20
+ ```
21
+ pip install sh-tiktok-studio
22
+ ```
23
+
24
+ ## Run
25
+
26
+ ```
27
+ tiktok-dl
28
+ ```
@@ -0,0 +1,16 @@
1
+ # SH TikTok Studio
2
+
3
+ TikTok downloader & search tool — no-watermark quality selection, paginated
4
+ search, batch download, and search history.
5
+
6
+ ## Install
7
+
8
+ ```
9
+ pip install sh-tiktok-studio
10
+ ```
11
+
12
+ ## Run
13
+
14
+ ```
15
+ tiktok-dl
16
+ ```
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sh-tiktok-studio"
7
+ version = "2.0.0"
8
+ description = "TikTok downloader & search tool with key-based access control, quality selection, and search history"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ dependencies = [
12
+ "requests",
13
+ "rich",
14
+ "pyfiglet",
15
+ "pymongo",
16
+ "dnspython",
17
+ ]
18
+
19
+ [project.scripts]
20
+ tiktok-dl = "sh_tiktok_studio.cli:main"
21
+
22
+ [tool.setuptools]
23
+ packages = ["sh_tiktok_studio"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,735 @@
1
+ import os
2
+ import re
3
+ import json
4
+ import time
5
+ import secrets
6
+ from datetime import datetime, timezone
7
+
8
+ import requests
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.align import Align
12
+ from rich.text import Text
13
+ from rich.rule import Rule
14
+ from rich.prompt import Prompt, IntPrompt
15
+ from rich.progress import Progress, BarColumn, TextColumn, TimeRemainingColumn, DownloadColumn, TransferSpeedColumn
16
+ from rich.table import Table
17
+ from rich.live import Live
18
+ from rich import box
19
+
20
+ try:
21
+ import pyfiglet
22
+ HAS_FIGLET = True
23
+ except ImportError:
24
+ HAS_FIGLET = False
25
+
26
+ try:
27
+ from pymongo import MongoClient
28
+ from pymongo.errors import PyMongoError
29
+ HAS_PYMONGO = True
30
+ except ImportError:
31
+ HAS_PYMONGO = False
32
+
33
+ console = Console()
34
+
35
+ HISTORY_FILE = os.path.expanduser("~/.sh_tiktok_history.json")
36
+ API_BASE = "https://www.tikwm.com/api/"
37
+ API_SEARCH = "https://www.tikwm.com/api/feed/search/"
38
+ UA_HEADERS = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36'}
39
+
40
+ LOGO_COLORS = ["bright_cyan", "cyan", "bright_magenta", "magenta", "bright_white", "cyan"]
41
+
42
+ # ==================== ACCESS CONTROL CONFIG ====================
43
+ # IMPORTANT: the Mongo connection string is read from an environment
44
+ # variable, NOT hardcoded here. This file gets published to PyPI — if the
45
+ # real connection string were written directly into this source file,
46
+ # literally everyone who runs `pip install` could open cli.py, copy the
47
+ # string, and connect to your database directly (read every key/username,
48
+ # generate their own "valid" keys, or delete the whole collection) —
49
+ # completely bypassing the admin panel and this app entirely.
50
+ #
51
+ # Set it on YOUR device (and only where you run the admin panel / where
52
+ # users run the app) with:
53
+ # export SH_TIKTOK_MONGO_URI="mongodb+srv://user:pass@cluster.mongodb.net"
54
+ MONGODB_URI = os.environ.get("SH_TIKTOK_MONGO_URI", "")
55
+ DB_NAME = os.environ.get("SH_TIKTOK_DB_NAME", "sh_tiktok_studio")
56
+ KEYS_COLLECTION = "access_keys"
57
+ ADMIN_SECRET = "sadesha2008"
58
+
59
+ _mongo_client = None
60
+
61
+ def get_keys_collection():
62
+ global _mongo_client
63
+ if not HAS_PYMONGO:
64
+ console.print("[bold red]❌ pymongo isn't installed. Run: pip install pymongo dnspython[/bold red]")
65
+ return None
66
+ if not MONGODB_URI:
67
+ console.print("[bold red]❌ SH_TIKTOK_MONGO_URI environment variable isn't set. See the top of cli.py for setup.[/bold red]")
68
+ return None
69
+ try:
70
+ if _mongo_client is None:
71
+ _mongo_client = MongoClient(MONGODB_URI, serverSelectionTimeoutMS=8000)
72
+ _mongo_client.admin.command('ping') # fail fast if unreachable
73
+ coll = _mongo_client[DB_NAME][KEYS_COLLECTION]
74
+ coll.create_index('key', unique=True)
75
+ return coll
76
+ except PyMongoError as e:
77
+ console.print(f"[bold red]❌ Could not connect to the license database: {e}[/bold red]")
78
+ return None
79
+ except Exception as e:
80
+ console.print(f"[bold red]❌ Database error: {e}[/bold red]")
81
+ return None
82
+
83
+
84
+ # ==================== UTILITIES ====================
85
+ def clear_screen():
86
+ os.system('cls' if os.name == 'nt' else 'clear')
87
+
88
+ def get_folder(subfolder):
89
+ primary = os.path.expanduser(f'/sdcard/Download/SH_Downloads/{subfolder}')
90
+ fallback = os.path.expanduser(f'./downloads/{subfolder}')
91
+ try:
92
+ os.makedirs(primary, exist_ok=True)
93
+ return primary
94
+ except Exception:
95
+ os.makedirs(fallback, exist_ok=True)
96
+ return fallback
97
+
98
+ def format_size(num_bytes):
99
+ if not num_bytes:
100
+ return "—"
101
+ mb = int(num_bytes) / (1024 * 1024)
102
+ if mb < 1:
103
+ return f"{int(num_bytes) / 1024:.0f} KB"
104
+ return f"{mb:.1f} MB"
105
+
106
+ def format_count(n):
107
+ try:
108
+ n = int(n)
109
+ except (TypeError, ValueError):
110
+ return "—"
111
+ if n >= 1_000_000:
112
+ return f"{n/1_000_000:.1f}M"
113
+ if n >= 1_000:
114
+ return f"{n/1_000:.1f}K"
115
+ return str(n)
116
+
117
+ def format_duration(seconds):
118
+ try:
119
+ seconds = int(seconds)
120
+ except (TypeError, ValueError):
121
+ seconds = 0
122
+ mins, secs = divmod(seconds, 60)
123
+ return f"{mins}:{secs:02d}"
124
+
125
+ def safe_filename(text, fallback="tiktok_video"):
126
+ clean = re.sub(r'[\\/*?:"<>|]', "", str(text))[:50].strip()
127
+ return clean or fallback
128
+
129
+
130
+ # ==================== SEARCH HISTORY ====================
131
+ def load_history():
132
+ try:
133
+ with open(HISTORY_FILE, 'r', encoding='utf-8') as f:
134
+ return json.load(f)
135
+ except Exception:
136
+ return []
137
+
138
+ def save_history(query):
139
+ history = load_history()
140
+ query = query.strip()
141
+ history = [h for h in history if h.lower() != query.lower()]
142
+ history.insert(0, query)
143
+ history = history[:15]
144
+ try:
145
+ with open(HISTORY_FILE, 'w', encoding='utf-8') as f:
146
+ json.dump(history, f, ensure_ascii=False, indent=2)
147
+ except Exception:
148
+ pass
149
+ return history
150
+
151
+
152
+ # ==================== UI: LOGO / BANNER / ANIMATION ====================
153
+ def build_logo_text():
154
+ width = console.size.width
155
+ if HAS_FIGLET:
156
+ try:
157
+ if width >= 80:
158
+ font = 'slant'
159
+ elif width >= 55:
160
+ font = 'standard'
161
+ else:
162
+ font = 'small'
163
+ fig = pyfiglet.Figlet(font=font, width=max(width, 40))
164
+ return fig.renderText("TikTok").rstrip('\n')
165
+ except Exception:
166
+ pass
167
+ return "đŸŽĩ T I K T O K đŸŽĩ"
168
+
169
+ def banner(subtitle="Verified Downloader & Search Engine"):
170
+ clear_screen()
171
+ logo = build_logo_text()
172
+ console.print(Align.center(Text(logo, style="bold bright_cyan")))
173
+ console.print(Align.center(Text(f"✨ {subtitle} ✨", style="bold yellow")))
174
+ console.print(Rule(style="magenta"))
175
+
176
+ def animated_intro():
177
+ clear_screen()
178
+ logo = build_logo_text()
179
+ with Live(console=console, refresh_per_second=15) as live:
180
+ for i in range(14):
181
+ color = LOGO_COLORS[i % len(LOGO_COLORS)]
182
+ live.update(Align.center(Text(logo, style=f"bold {color}")))
183
+ time.sleep(0.06)
184
+ live.update(Align.center(Text(logo, style="bold bright_cyan")))
185
+ time.sleep(0.3)
186
+ banner()
187
+
188
+
189
+ # ==================== ACCESS KEY SYSTEM ====================
190
+ def generate_key():
191
+ return "SH-" + secrets.token_hex(4).upper()
192
+
193
+ def admin_generate_keys(count):
194
+ coll = get_keys_collection()
195
+ if coll is None:
196
+ return []
197
+ created = []
198
+ for _ in range(count):
199
+ for _try in range(10):
200
+ k = generate_key()
201
+ try:
202
+ coll.insert_one({
203
+ 'key': k,
204
+ 'username': None,
205
+ 'status': 'unused',
206
+ 'created_at': datetime.now(timezone.utc),
207
+ })
208
+ created.append(k)
209
+ break
210
+ except PyMongoError:
211
+ continue # key collision (astronomically rare) — retry with a new one
212
+ return created
213
+
214
+ def admin_count_keys():
215
+ coll = get_keys_collection()
216
+ if coll is None:
217
+ return 0
218
+ return coll.count_documents({})
219
+
220
+ def admin_list_keys(page=1, per_page=20):
221
+ coll = get_keys_collection()
222
+ if coll is None:
223
+ return [], 0
224
+ total = coll.count_documents({})
225
+ cursor = coll.find({}).sort('created_at', 1).skip((page - 1) * per_page).limit(per_page)
226
+ return list(cursor), total
227
+
228
+ def validate_or_bind_key(key, username):
229
+ """Returns (ok: bool, message: str)."""
230
+ coll = get_keys_collection()
231
+ if coll is None:
232
+ return False, "Could not reach the license database."
233
+
234
+ doc = coll.find_one({'key': key})
235
+ if not doc:
236
+ return False, "Invalid key."
237
+
238
+ if doc.get('status') == 'unused':
239
+ coll.update_one(
240
+ {'_id': doc['_id']},
241
+ {'$set': {'username': username, 'status': 'used', 'activated_at': datetime.now(timezone.utc)}}
242
+ )
243
+ return True, f"Key activated — welcome, {username}!"
244
+
245
+ bound_username = (doc.get('username') or '')
246
+ if bound_username.lower() == username.lower():
247
+ return True, f"Welcome back, {username}!"
248
+
249
+ return False, "This key is already in use under a different username."
250
+
251
+
252
+ # ==================== ADMIN PANEL ====================
253
+ def admin_generate_keys_menu():
254
+ banner("ADMIN — Generate Keys")
255
+ count = IntPrompt.ask("[bold cyan]How many keys do you want to generate?[/bold cyan]", default=1)
256
+ if count <= 0:
257
+ return
258
+ with console.status(f"[bold green]Generating {count} key(s)...[/bold green]", spinner="dots12"):
259
+ created = admin_generate_keys(count)
260
+
261
+ if not created:
262
+ console.print("[bold red]❌ No keys were generated — check the database connection above.[/bold red]")
263
+ Prompt.ask("[dim]Press Enter to go back...[/dim]")
264
+ return
265
+
266
+ table = Table(title=f"✅ {len(created)} Key(s) Generated", border_style="green", box=box.ROUNDED)
267
+ table.add_column("No", justify="center")
268
+ table.add_column("Key", style="bold yellow")
269
+ for idx, k in enumerate(created, 1):
270
+ table.add_row(str(idx), k)
271
+ console.print(table)
272
+ Prompt.ask("[dim]Press Enter to go back...[/dim]")
273
+
274
+ def admin_view_keys_menu():
275
+ page = 1
276
+ per_page = 20
277
+ while True:
278
+ with console.status("[bold cyan]Loading keys...[/bold cyan]", spinner="dots12"):
279
+ keys, total = admin_list_keys(page=page, per_page=per_page)
280
+ total_pages = max(1, (total + per_page - 1) // per_page)
281
+
282
+ banner("ADMIN — All Keys")
283
+ table = Table(title=f"🔑 Access Keys (Page {page}/{total_pages}, {total} total)", border_style="cyan", box=box.ROUNDED)
284
+ table.add_column("Key", style="bold yellow")
285
+ table.add_column("Status", justify="center")
286
+ table.add_column("Username", style="white")
287
+
288
+ if not keys:
289
+ console.print("[yellow]No keys generated yet.[/yellow]")
290
+ else:
291
+ for doc in keys:
292
+ status_icon = "✅" if doc.get('status') == 'used' else "â¸ī¸"
293
+ table.add_row(doc.get('key', '—'), status_icon, doc.get('username') or "-")
294
+ console.print(table)
295
+
296
+ nav_bits = []
297
+ if page > 1:
298
+ nav_bits.append("[P]revious")
299
+ if page < total_pages:
300
+ nav_bits.append("[N]ext")
301
+ nav_bits.append("[0] Back")
302
+ console.print(f"\n[dim]{' '.join(nav_bits)}[/dim]")
303
+
304
+ choice = Prompt.ask("[bold yellow]Choice[/bold yellow]").strip().lower()
305
+ if choice in ('n', 'next') and page < total_pages:
306
+ page += 1
307
+ elif choice in ('p', 'prev', 'previous') and page > 1:
308
+ page -= 1
309
+ elif choice in ('0', 'back', 'exit'):
310
+ return
311
+ else:
312
+ console.print("[red]Invalid choice.[/red]")
313
+ time.sleep(1)
314
+
315
+ def admin_panel():
316
+ while True:
317
+ banner("đŸ›Ąī¸ ADMIN PANEL")
318
+ with console.status("[dim]Checking database...[/dim]", spinner="dots"):
319
+ total = admin_count_keys()
320
+ console.print(Align.center(Text(f"Total keys issued: {total}", style="bold dim")))
321
+ console.print()
322
+ console.print(" [1] 🆕 Generate new keys")
323
+ console.print(" [2] 📋 View all keys (used / unused)")
324
+ console.print(" [3] 🔙 Back to login\n")
325
+
326
+ choice = Prompt.ask("[bold yellow]Select option[/bold yellow]", choices=["1", "2", "3"])
327
+ if choice == "1":
328
+ admin_generate_keys_menu()
329
+ elif choice == "2":
330
+ admin_view_keys_menu()
331
+ elif choice == "3":
332
+ return
333
+
334
+
335
+ # ==================== LOGIN FLOW ====================
336
+ def login_flow():
337
+ while True:
338
+ banner()
339
+ console.print(Align.center(Text("🔒 ACCESS REQUIRED", style="bold underline bright_yellow")))
340
+ console.print()
341
+ entry = Prompt.ask("[bold cyan]🔑 Enter your access key[/bold cyan]").strip()
342
+
343
+ if entry == ADMIN_SECRET:
344
+ admin_panel()
345
+ continue
346
+
347
+ if not entry:
348
+ console.print("[red]A key is required.[/red]")
349
+ time.sleep(1)
350
+ continue
351
+
352
+ username = Prompt.ask("[bold cyan]👤 Enter your username[/bold cyan]").strip()
353
+ if not username:
354
+ console.print("[red]A username is required.[/red]")
355
+ time.sleep(1)
356
+ continue
357
+
358
+ with console.status("[bold green]Verifying key...[/bold green]", spinner="dots12"):
359
+ ok, msg = validate_or_bind_key(entry, username)
360
+
361
+ if ok:
362
+ console.print(f"[bold green]✔ {msg}[/bold green]")
363
+ time.sleep(1)
364
+ return username
365
+ else:
366
+ console.print(f"[bold red]❌ {msg}[/bold red]")
367
+ time.sleep(1.5)
368
+
369
+
370
+ # ==================== TIKWM API ====================
371
+ def get_video_full_info(url):
372
+ """Fetch full metadata + all quality links/sizes for a single video."""
373
+ data = {'url': url, 'count': 12, 'cursor': 0, 'web': 1, 'hd': 1}
374
+ try:
375
+ res = requests.post(API_BASE, data=data, headers=UA_HEADERS, timeout=15)
376
+ j = res.json()
377
+ if j.get('code') != 0:
378
+ console.print(f"[red]❌ TikTok API error: {j.get('msg', 'unknown error')}[/red]")
379
+ return None
380
+ return j.get('data')
381
+ except Exception as e:
382
+ console.print(f"[red]❌ Failed to fetch video info: {e}[/red]")
383
+ return None
384
+
385
+ def search_tiktok_page(query, cursor=0, count=10):
386
+ """One page of search results. Returns (results, has_more, next_cursor)."""
387
+ data = {'keywords': query, 'count': count, 'cursor': cursor, 'web': 1, 'hd': 1}
388
+ try:
389
+ res = requests.post(API_SEARCH, data=data, headers=UA_HEADERS, timeout=15)
390
+ j = res.json()
391
+ if j.get('code') != 0:
392
+ console.print(f"[dim]⚠ TikTok search API: {j.get('msg', 'unknown error')}[/dim]")
393
+ return [], False, cursor
394
+ payload = j.get('data') or {}
395
+ videos = payload.get('videos', [])
396
+ results = []
397
+ for v in videos:
398
+ vid = v.get('video_id') or v.get('id')
399
+ author = v.get('author') or {}
400
+ handle = author.get('unique_id') or author.get('uniqueId') or author.get('nickname')
401
+ if vid and handle:
402
+ url = f"https://www.tiktok.com/@{handle}/video/{vid}"
403
+ results.append({
404
+ 'title': (v.get('title') or vid).strip() or vid,
405
+ 'author': author.get('nickname') or handle,
406
+ 'duration': format_duration(v.get('duration', 0)),
407
+ 'plays': v.get('play_count'),
408
+ 'likes': v.get('digg_count'),
409
+ 'url': url,
410
+ })
411
+ has_more = bool(payload.get('hasMore', len(videos) >= count))
412
+ next_cursor = payload.get('cursor', cursor + count)
413
+ return results, has_more, next_cursor
414
+ except Exception as e:
415
+ console.print(f"[dim]⚠ TikTok search API failed: {e}[/dim]")
416
+ return [], False, cursor
417
+
418
+
419
+ # ==================== DOWNLOAD CORE ====================
420
+ def stream_download(url, filepath, label="Downloading"):
421
+ try:
422
+ r = requests.get(url, headers=UA_HEADERS, stream=True, timeout=30)
423
+ total_size = int(r.headers.get('content-length', 0))
424
+ with Progress(
425
+ TextColumn("[progress.description]{task.description}"),
426
+ BarColumn(bar_width=None),
427
+ DownloadColumn(),
428
+ TransferSpeedColumn(),
429
+ TimeRemainingColumn(),
430
+ console=console
431
+ ) as progress:
432
+ task = progress.add_task(f"[cyan]{label}...", total=total_size if total_size > 0 else None)
433
+ with open(filepath, 'wb') as f:
434
+ for chunk in r.iter_content(chunk_size=4096):
435
+ if chunk:
436
+ f.write(chunk)
437
+ if total_size > 0:
438
+ progress.update(task, advance=len(chunk))
439
+ return True
440
+ except Exception as e:
441
+ console.print(f"[bold red]❌ Download Error: {e}[/bold red]")
442
+ return False
443
+
444
+ def normalize_media_url(url):
445
+ """tikwm sometimes returns relative paths like '/video/media/wmplay/x.mp4'
446
+ instead of a full URL — that was causing 'Invalid URL: No scheme supplied'."""
447
+ if not url:
448
+ return url
449
+ if url.startswith('//'):
450
+ return 'https:' + url
451
+ if url.startswith('/'):
452
+ return 'https://www.tikwm.com' + url
453
+ return url
454
+
455
+ def build_quality_options(v):
456
+ """Video-only quality ladder — audio download was removed entirely."""
457
+ options = []
458
+ if v.get('hdplay'):
459
+ options.append({'label': 'HD (No Watermark)', 'url': normalize_media_url(v['hdplay']), 'size': v.get('hd_size'), 'kind': 'video', 'ext': 'mp4'})
460
+ if v.get('play'):
461
+ options.append({'label': 'SD (No Watermark)', 'url': normalize_media_url(v['play']), 'size': v.get('size'), 'kind': 'video', 'ext': 'mp4'})
462
+ if v.get('wmplay'):
463
+ options.append({'label': 'Watermarked', 'url': normalize_media_url(v['wmplay']), 'size': v.get('wm_size'), 'kind': 'video', 'ext': 'mp4'})
464
+ return options
465
+
466
+
467
+ def download_with_quality(v):
468
+ options = build_quality_options(v)
469
+ if not options:
470
+ console.print("[bold red]❌ No downloadable streams found for this video.[/bold red]")
471
+ return
472
+
473
+ table = Table(title="🎚 Available Quality Options", border_style="cyan", box=box.ROUNDED)
474
+ table.add_column("No", justify="center", style="bold yellow")
475
+ table.add_column("Quality", style="white")
476
+ table.add_column("Est. Size", style="green", justify="right")
477
+ for idx, opt in enumerate(options, 1):
478
+ table.add_row(str(idx), opt['label'], format_size(opt['size']))
479
+ console.print(table)
480
+
481
+ choice = IntPrompt.ask("[bold yellow]Select quality to download (0 to cancel)[/bold yellow]")
482
+ if choice == 0 or choice > len(options):
483
+ return
484
+
485
+ opt = options[choice - 1]
486
+ title_clean = safe_filename(v.get('title'))
487
+ folder = get_folder("TikTok/Video")
488
+ quality_tag = re.sub(r'[^A-Za-z0-9]+', '_', opt['label']).strip('_')
489
+ filepath = os.path.join(folder, f"{title_clean}_{quality_tag}.{opt['ext']}")
490
+
491
+ ok = stream_download(opt['url'], filepath, label=f"Downloading {opt['label']}")
492
+ if ok:
493
+ console.print(f"\n[bold green]✔ Saved to: TikTok/Video/{os.path.basename(filepath)}[/bold green]\n")
494
+
495
+
496
+ # ==================== VIDEO DETAILS ====================
497
+ def show_video_details(v):
498
+ author = v.get('author') or {}
499
+ table = Table(title="📋 Video Details", border_style="magenta", box=box.ROUNDED, show_header=False)
500
+ table.add_column("Field", style="bold cyan")
501
+ table.add_column("Value", style="white")
502
+
503
+ table.add_row("Title", (v.get('title') or '—')[:120])
504
+ table.add_row("Author", f"{author.get('nickname', '—')} (@{author.get('unique_id', '—')})")
505
+ table.add_row("Duration", format_duration(v.get('duration', 0)))
506
+ table.add_row("Plays", format_count(v.get('play_count')))
507
+ table.add_row("Likes", format_count(v.get('digg_count')))
508
+ table.add_row("Comments", format_count(v.get('comment_count')))
509
+ table.add_row("Shares", format_count(v.get('share_count')))
510
+ table.add_row("Downloads", format_count(v.get('download_count')))
511
+
512
+ created = v.get('create_time')
513
+ if created:
514
+ try:
515
+ table.add_row("Uploaded", datetime.fromtimestamp(int(created)).strftime('%Y-%m-%d %H:%M'))
516
+ except Exception:
517
+ pass
518
+
519
+ music = v.get('music_info') or {}
520
+ if music.get('title'):
521
+ table.add_row("Music", f"{music.get('title', '—')} — {music.get('author', '—')}")
522
+
523
+ console.print(table)
524
+
525
+
526
+ def video_detail_flow(basic_result):
527
+ with console.status("[bold green]Fetching full video details...[/bold green]", spinner="dots12"):
528
+ v = get_video_full_info(basic_result['url'])
529
+ if not v:
530
+ return
531
+ banner()
532
+ show_video_details(v)
533
+ console.print()
534
+ if Prompt.ask("[bold yellow]Download this video? (y/n)[/bold yellow]", choices=["y", "n"], default="y") == "y":
535
+ download_with_quality(v)
536
+
537
+
538
+ # ==================== DIRECT URL DOWNLOAD ====================
539
+ def download_tiktok_by_url(url):
540
+ with console.status("[bold green]Fetching TikTok video data...[/bold green]", spinner="dots12"):
541
+ v = get_video_full_info(url)
542
+ if not v:
543
+ console.print("[bold red]❌ Unable to parse TikTok URL. Make sure the link is public.[/bold red]")
544
+ return
545
+ banner()
546
+ show_video_details(v)
547
+ console.print()
548
+ download_with_quality(v)
549
+
550
+
551
+ # ==================== BATCH DOWNLOAD ====================
552
+ def batch_download(results, numbers_str):
553
+ try:
554
+ indices = [int(x.strip()) for x in numbers_str.split(',') if x.strip().isdigit()]
555
+ except ValueError:
556
+ console.print("[red]Invalid selection. Use comma-separated numbers like 1,3,5[/red]")
557
+ return
558
+
559
+ indices = [i for i in indices if 1 <= i <= len(results)]
560
+ if not indices:
561
+ console.print("[red]No valid numbers selected.[/red]")
562
+ return
563
+
564
+ console.print(f"[bold cyan]đŸ“Ļ Batch downloading {len(indices)} video(s) at best available quality...[/bold cyan]\n")
565
+ succeeded, failed = 0, 0
566
+ for n in indices:
567
+ item = results[n - 1]
568
+ console.print(Rule(f"[yellow]{n}. {item['title'][:40]}[/yellow]", style="dim"))
569
+ v = get_video_full_info(item['url'])
570
+ if not v:
571
+ failed += 1
572
+ continue
573
+ options = build_quality_options(v)
574
+ if not options:
575
+ failed += 1
576
+ continue
577
+ best = options[0] # HD listed first
578
+ title_clean = safe_filename(v.get('title'))
579
+ folder = get_folder("TikTok/Video")
580
+ filepath = os.path.join(folder, f"{title_clean}.{best['ext']}")
581
+ ok = stream_download(best['url'], filepath, label=f"{n}. {title_clean[:25]}")
582
+ if ok:
583
+ succeeded += 1
584
+ else:
585
+ failed += 1
586
+
587
+ console.print(f"\n[bold green]✔ Batch complete — {succeeded} succeeded, {failed} failed[/bold green]\n")
588
+
589
+
590
+ # ==================== SEARCH & BROWSE (PAGINATED) ====================
591
+ def search_tiktok_paginated(query):
592
+ cursor = 0
593
+ page = 1
594
+ cursor_stack = []
595
+
596
+ while True:
597
+ with console.status(f"[bold cyan]Searching '{query}' (page {page})...[/bold cyan]", spinner="bouncingBall"):
598
+ results, has_more, next_cursor = search_tiktok_page(query, cursor=cursor, count=10)
599
+
600
+ if not results:
601
+ if page == 1:
602
+ console.print("[yellow]No search results found for this keyword.[/yellow]")
603
+ return
604
+ else:
605
+ console.print("[yellow]No more results.[/yellow]")
606
+ page -= 1
607
+ cursor = cursor_stack.pop() if cursor_stack else 0
608
+ continue
609
+
610
+ banner()
611
+ table = Table(title=f"🔎 TikTok Search — '{query}' (Page {page})", border_style="yellow", box=box.ROUNDED)
612
+ table.add_column("No", style="cyan", justify="center")
613
+ table.add_column("Title", style="white")
614
+ table.add_column("Author", style="green")
615
+ table.add_column("Duration", style="magenta", justify="center")
616
+ table.add_column("Likes", style="red", justify="right")
617
+
618
+ for idx, r in enumerate(results, 1):
619
+ title_short = (r['title'][:38] + "â€Ļ") if len(r['title']) > 38 else r['title']
620
+ table.add_row(str(idx), title_short, r['author'], r['duration'], format_count(r['likes']))
621
+
622
+ console.print(table)
623
+
624
+ nav_bits = []
625
+ if page > 1:
626
+ nav_bits.append("[P]revious")
627
+ if has_more:
628
+ nav_bits.append("[N]ext")
629
+ nav_bits.append("[B]atch download (e.g. b 1,3,5)")
630
+ nav_bits.append("[0] Back to menu")
631
+ console.print(f"[dim]{' '.join(nav_bits)} or type a number for full details[/dim]")
632
+
633
+ choice = Prompt.ask("[bold yellow]Choice[/bold yellow]").strip().lower()
634
+
635
+ if choice in ('n', 'next') and has_more:
636
+ cursor_stack.append(cursor)
637
+ cursor = next_cursor
638
+ page += 1
639
+ elif choice in ('p', 'prev', 'previous') and page > 1:
640
+ page -= 1
641
+ cursor = cursor_stack.pop() if cursor_stack else 0
642
+ elif choice.startswith('b'):
643
+ nums = choice[1:].strip()
644
+ if not nums:
645
+ nums = Prompt.ask("[bold cyan]Enter numbers to batch download (e.g. 1,3,5)[/bold cyan]")
646
+ batch_download(results, nums)
647
+ Prompt.ask("[dim]Press Enter to continue...[/dim]")
648
+ elif choice in ('0', 'back', 'exit'):
649
+ return
650
+ elif choice.isdigit() and 1 <= int(choice) <= len(results):
651
+ video_detail_flow(results[int(choice) - 1])
652
+ Prompt.ask("[dim]Press Enter to continue...[/dim]")
653
+ else:
654
+ console.print("[red]Invalid choice.[/red]")
655
+ time.sleep(1)
656
+
657
+
658
+ # ==================== MENUS ====================
659
+ def search_menu():
660
+ history = load_history()
661
+ banner()
662
+ if history:
663
+ console.print("[bold cyan]🕘 Recent searches:[/bold cyan]")
664
+ for i, h in enumerate(history[:8], 1):
665
+ console.print(f" [dim]{i}.[/dim] {h}")
666
+ console.print()
667
+ query = Prompt.ask("[bold cyan]Enter search keyword (or a number from recent searches)[/bold cyan]")
668
+ query = query.strip()
669
+ if query.isdigit() and 1 <= int(query) <= len(history[:8]):
670
+ query = history[int(query) - 1]
671
+ if not query:
672
+ return
673
+ save_history(query)
674
+ search_tiktok_paginated(query)
675
+
676
+ def about_menu():
677
+ banner()
678
+ folder = get_folder("TikTok/Video")
679
+ table = Table(title="â„šī¸ About / Settings", border_style="cyan", box=box.ROUNDED, show_header=False)
680
+ table.add_column("Field", style="bold cyan")
681
+ table.add_column("Value", style="white")
682
+ table.add_row("Download folder", folder)
683
+ try:
684
+ n_files = len([f for f in os.listdir(folder) if os.path.isfile(os.path.join(folder, f))])
685
+ except Exception:
686
+ n_files = 0
687
+ table.add_row("Videos downloaded (this device)", str(n_files))
688
+ table.add_row("Search history entries", str(len(load_history())))
689
+ console.print(table)
690
+ Prompt.ask("\n[dim]Press Enter to go back...[/dim]")
691
+
692
+ def main_menu(username):
693
+ while True:
694
+ banner(f"Logged in as {username}")
695
+ console.print(Align.center(Text("MAIN MENU", style="bold underline bright_yellow")))
696
+ console.print()
697
+ console.print(" [1] 🔗 Download via TikTok Link")
698
+ console.print(" [2] 🔎 Search & Browse Videos")
699
+ console.print(" [3] 🕘 Search History")
700
+ console.print(" [4] â„šī¸ About / Settings")
701
+ console.print(" [5] đŸšĒ Exit\n")
702
+
703
+ choice = Prompt.ask("[bold yellow]Select option[/bold yellow]", choices=["1", "2", "3", "4", "5"])
704
+
705
+ if choice == "1":
706
+ url = Prompt.ask("[bold cyan]Enter TikTok URL[/bold cyan]").strip()
707
+ if url:
708
+ download_tiktok_by_url(url)
709
+ Prompt.ask("[dim]Press Enter to continue...[/dim]")
710
+ elif choice == "2":
711
+ search_menu()
712
+ elif choice == "3":
713
+ history = load_history()
714
+ banner()
715
+ if not history:
716
+ console.print("[yellow]No search history yet.[/yellow]")
717
+ else:
718
+ for i, h in enumerate(history, 1):
719
+ console.print(f" [cyan]{i}.[/cyan] {h}")
720
+ Prompt.ask("\n[dim]Press Enter to go back...[/dim]")
721
+ elif choice == "4":
722
+ about_menu()
723
+ elif choice == "5":
724
+ banner("Thanks for using SH TikTok Studio!")
725
+ console.print(Align.center(Text("👋 Goodbye!", style="bold magenta")))
726
+ break
727
+
728
+
729
+ def main():
730
+ animated_intro()
731
+ username = login_flow()
732
+ main_menu(username)
733
+
734
+ if __name__ == "__main__":
735
+ main()
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: sh-tiktok-studio
3
+ Version: 2.0.0
4
+ Summary: TikTok downloader & search tool with key-based access control, quality selection, and search history
5
+ Requires-Python: >=3.8
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: requests
8
+ Requires-Dist: rich
9
+ Requires-Dist: pyfiglet
10
+ Requires-Dist: pymongo
11
+ Requires-Dist: dnspython
12
+
13
+ # SH TikTok Studio
14
+
15
+ TikTok downloader & search tool — no-watermark quality selection, paginated
16
+ search, batch download, and search history.
17
+
18
+ ## Install
19
+
20
+ ```
21
+ pip install sh-tiktok-studio
22
+ ```
23
+
24
+ ## Run
25
+
26
+ ```
27
+ tiktok-dl
28
+ ```
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ sh_tiktok_studio/__init__.py
4
+ sh_tiktok_studio/cli.py
5
+ sh_tiktok_studio.egg-info/PKG-INFO
6
+ sh_tiktok_studio.egg-info/SOURCES.txt
7
+ sh_tiktok_studio.egg-info/dependency_links.txt
8
+ sh_tiktok_studio.egg-info/entry_points.txt
9
+ sh_tiktok_studio.egg-info/requires.txt
10
+ sh_tiktok_studio.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tiktok-dl = sh_tiktok_studio.cli:main
@@ -0,0 +1,5 @@
1
+ requests
2
+ rich
3
+ pyfiglet
4
+ pymongo
5
+ dnspython
@@ -0,0 +1 @@
1
+ sh_tiktok_studio