java2-extention 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.
- java2_extention/__init__.py +1 -0
- java2_extention/__main__.py +325 -0
- java2_extention/cache.py +52 -0
- java2_extention/config.py +75 -0
- java2_extention/downloader.py +525 -0
- java2_extention/http_client.py +232 -0
- java2_extention/scraper.py +345 -0
- java2_extention-1.0.0.dist-info/METADATA +53 -0
- java2_extention-1.0.0.dist-info/RECORD +12 -0
- java2_extention-1.0.0.dist-info/WHEEL +5 -0
- java2_extention-1.0.0.dist-info/entry_points.txt +2 -0
- java2_extention-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""
|
|
2
|
+
java2 — freepornvideos.xxx search + threaded downloader
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
java2 # interactive menu
|
|
6
|
+
java2 "milf" # search immediately
|
|
7
|
+
java2 --recent # browse recent uploads
|
|
8
|
+
java2 --recent -p 2 # recent page 2
|
|
9
|
+
java2 -t 4 # 4 download threads
|
|
10
|
+
java2 -o ~/dl # custom output dir
|
|
11
|
+
java2 --clear-cache # wipe search cache
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
import argparse
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
from rich.live import Live
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
from rich import box
|
|
27
|
+
|
|
28
|
+
from .scraper import FPV, Video, VideoEntry
|
|
29
|
+
from .downloader import download_direct, download_hls
|
|
30
|
+
from . import cache as _cache
|
|
31
|
+
from . import config as _config
|
|
32
|
+
from .http_client import HttpClient
|
|
33
|
+
|
|
34
|
+
console = Console(stderr=False)
|
|
35
|
+
VERSION = "1.0.0"
|
|
36
|
+
|
|
37
|
+
_cfg = _config.load()
|
|
38
|
+
_DEFAULT_THREADS = _cfg["threads"]
|
|
39
|
+
DOWNLOAD_DIR = Path(_cfg["download_dir"]).expanduser()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _banner() -> None:
|
|
43
|
+
console.print(Panel.fit(
|
|
44
|
+
f"[bold cyan]java2[/bold cyan] [dim]v{VERSION}[/dim]\n"
|
|
45
|
+
f"[dim]freepornvideos.xxx · direct MP4 + HLS · {_DEFAULT_THREADS} threads · stream-to-disk[/dim]",
|
|
46
|
+
border_style="cyan",
|
|
47
|
+
))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ask(prompt: str, lo: int = 1, hi: int = 99, default: int = 1) -> int:
|
|
51
|
+
while True:
|
|
52
|
+
try:
|
|
53
|
+
raw = input(f"\n {prompt} [{lo}-{hi}] (default {default}): ").strip()
|
|
54
|
+
if raw == "":
|
|
55
|
+
return default - 1
|
|
56
|
+
n = int(raw)
|
|
57
|
+
if lo <= n <= hi:
|
|
58
|
+
return n - 1
|
|
59
|
+
console.print(f" [red]Enter a number between {lo} and {hi}[/red]")
|
|
60
|
+
except (ValueError, EOFError):
|
|
61
|
+
return default - 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _ask_str(prompt: str, default: str = "") -> str:
|
|
65
|
+
try:
|
|
66
|
+
hint = f" [{default}]" if default else ""
|
|
67
|
+
val = input(f"\n {prompt}{hint}: ").strip()
|
|
68
|
+
return val if val else default
|
|
69
|
+
except EOFError:
|
|
70
|
+
return default
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _show_results(videos: list[Video], title: str = "Results") -> None:
|
|
74
|
+
table = Table(
|
|
75
|
+
show_header=True, header_style="bold cyan",
|
|
76
|
+
box=box.SIMPLE_HEAVY, show_lines=False, title=title,
|
|
77
|
+
title_style="bold white",
|
|
78
|
+
)
|
|
79
|
+
table.add_column("#", style="bold dim", width=4)
|
|
80
|
+
table.add_column("ID", style="bold yellow", width=10)
|
|
81
|
+
table.add_column("Title", min_width=36)
|
|
82
|
+
table.add_column("Duration", width=8, style="dim")
|
|
83
|
+
|
|
84
|
+
for i, v in enumerate(videos, 1):
|
|
85
|
+
table.add_row(str(i), v.video_id, (v.title or "")[:60], v.duration or "—")
|
|
86
|
+
console.print(table)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _show_qualities(entries: list[dict]) -> None:
|
|
90
|
+
table = Table(
|
|
91
|
+
show_header=True, header_style="bold magenta",
|
|
92
|
+
box=box.SIMPLE, title="Available qualities",
|
|
93
|
+
)
|
|
94
|
+
table.add_column("#", width=4)
|
|
95
|
+
table.add_column("Quality", width=8)
|
|
96
|
+
table.add_column("Format", width=8)
|
|
97
|
+
table.add_column("URL", no_wrap=False, overflow="fold")
|
|
98
|
+
|
|
99
|
+
for i, e in enumerate(entries, 1):
|
|
100
|
+
table.add_row(
|
|
101
|
+
str(i),
|
|
102
|
+
(e.get("quality") or "?").upper(),
|
|
103
|
+
(e.get("ext") or "?").upper(),
|
|
104
|
+
e.get("url", "")[:80],
|
|
105
|
+
)
|
|
106
|
+
console.print(table)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _render_bar(done: int, total: int, speed: float, label: str = "Downloading") -> Text:
|
|
110
|
+
if total and total > 0:
|
|
111
|
+
pct = min(100, int(done * 100 / total))
|
|
112
|
+
else:
|
|
113
|
+
pct = 0
|
|
114
|
+
fill = "█" * (pct // 4)
|
|
115
|
+
empty = "░" * (25 - pct // 4)
|
|
116
|
+
spd = (f"{speed/1_000_000:.2f} MB/s" if speed >= 1_000_000
|
|
117
|
+
else f"{speed/1_000:.0f} KB/s" if speed >= 1_000
|
|
118
|
+
else f"{speed:.0f} B/s")
|
|
119
|
+
t = Text()
|
|
120
|
+
t.append(f"{label} ", style="bold white")
|
|
121
|
+
t.append(fill, style="cyan")
|
|
122
|
+
t.append(empty, style="dim")
|
|
123
|
+
t.append(f" {pct:3d}% " if total else f" {done//1_048_576} MB ", style="bold white")
|
|
124
|
+
t.append(f" {spd}", style="green")
|
|
125
|
+
return t
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _search_cached(api: FPV, query: str) -> list[Video]:
|
|
129
|
+
cached = _cache.get(query)
|
|
130
|
+
if cached:
|
|
131
|
+
console.print(f" [dim](cache hit for '{query}')[/dim]")
|
|
132
|
+
return [Video.from_dict(d) for d in cached]
|
|
133
|
+
|
|
134
|
+
console.print(f" [cyan]Searching:[/cyan] {query}")
|
|
135
|
+
results = api.search(query)
|
|
136
|
+
console.print(f" [green]→ {len(results)} result(s)[/green]")
|
|
137
|
+
|
|
138
|
+
if results:
|
|
139
|
+
_cache.put(query, [v.to_dict() for v in results])
|
|
140
|
+
return results
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _do_download(
|
|
144
|
+
api: FPV,
|
|
145
|
+
video: Video,
|
|
146
|
+
out_dir: Path,
|
|
147
|
+
threads: int,
|
|
148
|
+
) -> None:
|
|
149
|
+
console.print(f"\n [cyan]Fetching video page…[/cyan]")
|
|
150
|
+
video = api.video_details(video)
|
|
151
|
+
|
|
152
|
+
if not video.entries:
|
|
153
|
+
console.print("\n [bold red]✗ No video URLs found on page.[/bold red]")
|
|
154
|
+
console.print(f" [dim]URL:[/dim] {video.page_url}")
|
|
155
|
+
console.print(" [dim]Page may use JS player — try visiting it in a browser.[/dim]")
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
console.print(Panel(
|
|
159
|
+
f"[bold yellow]{video.video_id}[/bold yellow] {video.title}\n"
|
|
160
|
+
f"[dim]Page:[/dim] {video.page_url}",
|
|
161
|
+
border_style="dim",
|
|
162
|
+
))
|
|
163
|
+
|
|
164
|
+
# filter to HLS or direct files only
|
|
165
|
+
usable = [e for e in video.entries if e.get("ext") in ("mp4", "webm", "m3u8")]
|
|
166
|
+
if not usable:
|
|
167
|
+
usable = video.entries # show everything if nothing direct
|
|
168
|
+
|
|
169
|
+
_show_qualities(usable)
|
|
170
|
+
if not usable:
|
|
171
|
+
console.print(" [red]✗ No downloadable URLs.[/red]")
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
choice = _ask("Pick quality", 1, len(usable), 1)
|
|
175
|
+
entry = usable[choice]
|
|
176
|
+
|
|
177
|
+
url = entry.get("url", "")
|
|
178
|
+
ext = entry.get("ext", "mp4")
|
|
179
|
+
q = (entry.get("quality") or "unknown").lower()
|
|
180
|
+
|
|
181
|
+
safe_title = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", video.title or video.video_id)[:60]
|
|
182
|
+
fname = f"{video.video_id}_{q}.{ext if ext != 'm3u8' else 'mp4'}"
|
|
183
|
+
out_path = out_dir / fname
|
|
184
|
+
|
|
185
|
+
console.print(f"\n [green]Downloading:[/green] {fname}")
|
|
186
|
+
console.print(f" [dim]→ {out_path}[/dim]")
|
|
187
|
+
|
|
188
|
+
bar_text = Text("Starting…")
|
|
189
|
+
|
|
190
|
+
def _prog(done: int, total: int, speed: float) -> None:
|
|
191
|
+
nonlocal bar_text
|
|
192
|
+
bar_text = _render_bar(done, total, speed)
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
with Live(bar_text, console=console, refresh_per_second=4) as live:
|
|
196
|
+
def _prog2(done: int, total: int, speed: float) -> None:
|
|
197
|
+
live.update(_render_bar(done, total, speed))
|
|
198
|
+
|
|
199
|
+
if ext == "m3u8":
|
|
200
|
+
download_hls(url, out_path, threads=threads, progress=_prog2)
|
|
201
|
+
else:
|
|
202
|
+
download_direct(url, out_path, referer=video.page_url, threads=threads, progress=_prog2)
|
|
203
|
+
|
|
204
|
+
except KeyboardInterrupt:
|
|
205
|
+
console.print("\n [yellow]Interrupted.[/yellow]")
|
|
206
|
+
return
|
|
207
|
+
except Exception as e:
|
|
208
|
+
console.print(f"\n [bold red]✗ Download failed:[/bold red] {e}")
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
if out_path.exists():
|
|
212
|
+
size = out_path.stat().st_size
|
|
213
|
+
console.print(Panel.fit(
|
|
214
|
+
f"✓ Done!\n"
|
|
215
|
+
f"File: {out_path}\n"
|
|
216
|
+
f"Size: {size/1_048_576:.1f} MB | Quality: {q}",
|
|
217
|
+
border_style="green",
|
|
218
|
+
))
|
|
219
|
+
else:
|
|
220
|
+
console.print(" [red]✗ Output file not found after download.[/red]")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def run(args: argparse.Namespace) -> None:
|
|
224
|
+
cfg = _config.load()
|
|
225
|
+
http = HttpClient(timeout=30.0, min_delay=3.0)
|
|
226
|
+
api = FPV(http)
|
|
227
|
+
out_dir = Path(args.output if args.output != str(DOWNLOAD_DIR)
|
|
228
|
+
else cfg["download_dir"]).expanduser().resolve()
|
|
229
|
+
if args.threads == _DEFAULT_THREADS:
|
|
230
|
+
args.threads = cfg["threads"]
|
|
231
|
+
|
|
232
|
+
if args.recent:
|
|
233
|
+
page = getattr(args, "page", 1) or 1
|
|
234
|
+
console.print(f" [cyan]Loading recent uploads (page {page})…[/cyan]")
|
|
235
|
+
results = api.recent(page=page)
|
|
236
|
+
console.print(f" [green]→ {len(results)} video(s)[/green]")
|
|
237
|
+
if not results:
|
|
238
|
+
console.print(" [red]✗ No recent results. Check connection.[/red]")
|
|
239
|
+
return
|
|
240
|
+
_show_results(results, f"Recent (page {page})")
|
|
241
|
+
selected = results[_ask("Pick video", 1, len(results), 1)]
|
|
242
|
+
|
|
243
|
+
elif args.query:
|
|
244
|
+
results = _search_cached(api, args.query)
|
|
245
|
+
if not results:
|
|
246
|
+
console.print(f"\n [red]✗ No results for '{args.query}'[/red]")
|
|
247
|
+
console.print(" [dim]Tip: try simpler keywords like 'milf', 'amateur', etc.[/dim]")
|
|
248
|
+
return
|
|
249
|
+
_show_results(results, f"'{args.query}' ({len(results)} result(s))")
|
|
250
|
+
if len(results) == 1:
|
|
251
|
+
selected = results[0]
|
|
252
|
+
console.print(f" [green]Auto-selected:[/green] {selected.title}")
|
|
253
|
+
else:
|
|
254
|
+
selected = results[_ask("Pick video", 1, len(results), 1)]
|
|
255
|
+
|
|
256
|
+
else:
|
|
257
|
+
console.print("\n [bold]1.[/bold] Search")
|
|
258
|
+
console.print(" [bold]2.[/bold] Recent uploads")
|
|
259
|
+
console.print(" [bold]3.[/bold] Settings")
|
|
260
|
+
console.print(" [bold]4.[/bold] Clear cache\n")
|
|
261
|
+
choice = _ask("Select", 1, 4, 1)
|
|
262
|
+
|
|
263
|
+
if choice == 2:
|
|
264
|
+
_cfg2 = _config.edit_interactive()
|
|
265
|
+
console.print(f" [dim]Download dir:[/dim] {_cfg2['download_dir']}")
|
|
266
|
+
console.print(f" [dim]Threads:[/dim] {_cfg2['threads']}")
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
if choice == 3:
|
|
270
|
+
_cache.clear()
|
|
271
|
+
console.print(" [green]✓ Cache cleared.[/green]")
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
if choice == 1:
|
|
275
|
+
# 2. Recent uploads
|
|
276
|
+
console.print(" [cyan]Loading recent uploads…[/cyan]")
|
|
277
|
+
results = api.recent()
|
|
278
|
+
console.print(f" [green]→ {len(results)} video(s)[/green]")
|
|
279
|
+
else:
|
|
280
|
+
# 1. Search (choice == 0)
|
|
281
|
+
q = _ask_str("Search query")
|
|
282
|
+
if not q:
|
|
283
|
+
return
|
|
284
|
+
results = _search_cached(api, q)
|
|
285
|
+
|
|
286
|
+
if not results:
|
|
287
|
+
console.print(" [red]✗ No results.[/red]")
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
_show_results(results)
|
|
291
|
+
selected = results[_ask("Pick video", 1, len(results), 1)]
|
|
292
|
+
|
|
293
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
_do_download(api, selected, out_dir, threads=args.threads)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def main() -> None:
|
|
298
|
+
_banner()
|
|
299
|
+
|
|
300
|
+
parser = argparse.ArgumentParser(
|
|
301
|
+
prog="java2",
|
|
302
|
+
description="freepornvideos.xxx downloader — direct MP4 + HLS, Termux/2GB safe",
|
|
303
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
304
|
+
epilog=__doc__,
|
|
305
|
+
)
|
|
306
|
+
parser.add_argument("query", nargs="?", default="", help="Search query")
|
|
307
|
+
parser.add_argument("--recent", action="store_true", help="Browse recent uploads")
|
|
308
|
+
parser.add_argument("-p", "--page", type=int, default=1, help="Recent page number (default 1)")
|
|
309
|
+
parser.add_argument("-t", "--threads", type=int,
|
|
310
|
+
default=_DEFAULT_THREADS, help=f"Download threads (default {_DEFAULT_THREADS})")
|
|
311
|
+
parser.add_argument("-o", "--output", default=str(DOWNLOAD_DIR), help="Output directory")
|
|
312
|
+
parser.add_argument("--clear-cache", action="store_true", help="Clear search cache and exit")
|
|
313
|
+
|
|
314
|
+
args = parser.parse_args()
|
|
315
|
+
|
|
316
|
+
if args.clear_cache:
|
|
317
|
+
_cache.clear()
|
|
318
|
+
console.print(" [green]✓ Cache cleared.[/green]")
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
run(args)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__":
|
|
325
|
+
main()
|
java2_extention/cache.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
_CACHE_DIR = Path.home() / ".cache" / "fpv-dl"
|
|
8
|
+
_CACHE_FILE = _CACHE_DIR / "search.json"
|
|
9
|
+
_TTL = 1800
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load() -> dict:
|
|
13
|
+
try:
|
|
14
|
+
if _CACHE_FILE.exists():
|
|
15
|
+
with open(_CACHE_FILE, "r", encoding="utf-8") as f:
|
|
16
|
+
return json.load(f)
|
|
17
|
+
except Exception:
|
|
18
|
+
pass
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _save(data: dict) -> None:
|
|
23
|
+
try:
|
|
24
|
+
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
with open(_CACHE_FILE, "w", encoding="utf-8") as f:
|
|
26
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
27
|
+
except Exception:
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get(query: str) -> Optional[list[dict]]:
|
|
32
|
+
data = _load()
|
|
33
|
+
key = query.strip().lower()
|
|
34
|
+
entry = data.get(key)
|
|
35
|
+
if entry and time.time() - entry.get("ts", 0) < _TTL:
|
|
36
|
+
return entry["results"]
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def put(query: str, results: list[dict]) -> None:
|
|
41
|
+
data = _load()
|
|
42
|
+
key = query.strip().lower()
|
|
43
|
+
data[key] = {"ts": time.time(), "results": results}
|
|
44
|
+
_save(data)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def clear() -> None:
|
|
48
|
+
try:
|
|
49
|
+
if _CACHE_FILE.exists():
|
|
50
|
+
_CACHE_FILE.unlink()
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import platform
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
_CFG_PATH = Path.home() / ".config" / "java2-extention" / "config.json"
|
|
8
|
+
|
|
9
|
+
_IS_TERMUX = (
|
|
10
|
+
platform.machine() in ("aarch64", "armv7l")
|
|
11
|
+
or platform.system() == "Android"
|
|
12
|
+
or "com.termux" in sys.executable
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _default_download_dir() -> str:
|
|
17
|
+
if _IS_TERMUX:
|
|
18
|
+
candidates = [
|
|
19
|
+
Path("/sdcard/Download"),
|
|
20
|
+
Path("/storage/emulated/0/Download"),
|
|
21
|
+
]
|
|
22
|
+
for c in candidates:
|
|
23
|
+
if c.exists():
|
|
24
|
+
return str(c)
|
|
25
|
+
return str(Path.home() / "downloads")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_DEFAULTS: dict = {
|
|
29
|
+
"download_dir": _default_download_dir(),
|
|
30
|
+
"threads": 8,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load() -> dict:
|
|
35
|
+
try:
|
|
36
|
+
if _CFG_PATH.exists():
|
|
37
|
+
with open(_CFG_PATH, "r", encoding="utf-8") as f:
|
|
38
|
+
data = json.load(f)
|
|
39
|
+
return {**_DEFAULTS, **data}
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
return dict(_DEFAULTS)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save(cfg: dict) -> None:
|
|
46
|
+
_CFG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
with open(_CFG_PATH, "w", encoding="utf-8") as f:
|
|
48
|
+
json.dump(cfg, f, indent=2)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def edit_interactive() -> dict:
|
|
52
|
+
cfg = load()
|
|
53
|
+
print()
|
|
54
|
+
print(" ── Config editor ──────────────────────────")
|
|
55
|
+
print(f" 1. Download directory → {cfg['download_dir']}")
|
|
56
|
+
print(f" 2. Threads → {cfg['threads']}")
|
|
57
|
+
print(f" 3. Save and exit")
|
|
58
|
+
print()
|
|
59
|
+
|
|
60
|
+
while True:
|
|
61
|
+
raw = input(" Select [1-3]: ").strip()
|
|
62
|
+
if raw == "3" or raw == "":
|
|
63
|
+
break
|
|
64
|
+
if raw == "1":
|
|
65
|
+
new = input(f" New download dir [{cfg['download_dir']}]: ").strip()
|
|
66
|
+
if new:
|
|
67
|
+
cfg["download_dir"] = str(Path(new).expanduser())
|
|
68
|
+
elif raw == "2":
|
|
69
|
+
new = input(f" Threads [{cfg['threads']}]: ").strip()
|
|
70
|
+
if new.isdigit():
|
|
71
|
+
cfg["threads"] = int(new)
|
|
72
|
+
|
|
73
|
+
save(cfg)
|
|
74
|
+
print(f" ✓ Saved to {_CFG_PATH}")
|
|
75
|
+
return cfg
|