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.
- sh_tiktok_studio-2.0.0/PKG-INFO +28 -0
- sh_tiktok_studio-2.0.0/README.md +16 -0
- sh_tiktok_studio-2.0.0/pyproject.toml +23 -0
- sh_tiktok_studio-2.0.0/setup.cfg +4 -0
- sh_tiktok_studio-2.0.0/sh_tiktok_studio/__init__.py +1 -0
- sh_tiktok_studio-2.0.0/sh_tiktok_studio/cli.py +735 -0
- sh_tiktok_studio-2.0.0/sh_tiktok_studio.egg-info/PKG-INFO +28 -0
- sh_tiktok_studio-2.0.0/sh_tiktok_studio.egg-info/SOURCES.txt +10 -0
- sh_tiktok_studio-2.0.0/sh_tiktok_studio.egg-info/dependency_links.txt +1 -0
- sh_tiktok_studio-2.0.0/sh_tiktok_studio.egg-info/entry_points.txt +2 -0
- sh_tiktok_studio-2.0.0/sh_tiktok_studio.egg-info/requires.txt +5 -0
- sh_tiktok_studio-2.0.0/sh_tiktok_studio.egg-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sh_tiktok_studio
|