Skip to content

Commit 7853cb7

Browse files
authored
✨ feat!: release v0.5.0 with api integration fixes and ux overhaul
- Critical functionality was broken and is now fixed - Major user-facing improvements (colored logging, CLI, progress bars) - Complete architecture restructuring
2 parents 3759c4d + 84f7552 commit 7853cb7

9 files changed

Lines changed: 1395 additions & 526 deletions

File tree

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,11 @@ ruff.toml
171171
# Development docs
172172
audit.md
173173
TODO.md
174+
175+
# uv lock file
176+
uv.lock
177+
178+
# local
179+
backups/
180+
scripts/
181+
analysis/

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,6 @@
3838

3939
> [!NOTE]
4040
>
41-
> - If `-d` or `--directory` is not invoked, TokySnatcher will download the books in current directory.
41+
> - By default, TokySnatcher saves audiobooks to your system's Music folder in an "Audiobooks" subfolder (e.g., `C:\Users\User\Music\Audiobooks\` on Windows)
42+
> - Use `-d` or `--directory` to specify a custom location
4243
>

pyproject.toml

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,13 @@
11
[project]
22
name = "tokysnatcher"
3-
version = "0.4.0"
3+
version = "0.5.0"
44
description = "A simple audiobook downloader for tokybook.com"
55
readme = "README.md"
66
requires-python = ">=3.9,<3.15"
77
dependencies = [
8-
"gazpacho==1.1",
9-
"halo==0.0.31",
10-
"json5==0.12.0",
11-
"pebble==5.1.3",
12-
13-
"questionary==2.1.1",
14-
"requests==2.32.3",
8+
"questionary>=2.1.1",
9+
"requests>=2.32.3",
1510
"rich",
16-
"urllib3==2.4.0",
1711
]
1812
classifiers = [
1913
"Programming Language :: Python :: 3",
@@ -31,3 +25,6 @@ tokysnatcher = 'tokysnatcher.__main__:main'
3125
[build-system]
3226
requires = ["setuptools>=61.0", "wheel"]
3327
build-backend = "setuptools.build_meta"
28+
29+
[tool.setuptools.packages.find]
30+
exclude = ["analysis*"]

requirements.txt

Lines changed: 0 additions & 8 deletions
This file was deleted.

tokysnatcher/__main__.py

Lines changed: 148 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,47 @@
1+
from dataclasses import dataclass
12
import argparse
2-
from os import name, system
3-
import questionary
4-
import shutil
5-
import sys
63
import logging
74
import platform
5+
import shutil
6+
import sys
7+
from os import name, system
8+
from pathlib import Path
9+
10+
import questionary
811

912
from .chapters import get_chapters
1013
from .search import search_book
1114

1215
# Terminal settings for Unix-like systems
1316
if platform.system() != "Windows":
1417
import termios
18+
1519
fd: int = sys.stdin.fileno()
1620
old_term_settings: list[int] = termios.tcgetattr(fd)
1721
else:
1822
termios = None
1923

24+
from .utils import setup_colored_logging
25+
2026
# Configure logging
21-
logging.basicConfig(
22-
level=logging.INFO,
23-
format="%(asctime)s - %(levelname)s - %(message)s",
24-
datefmt="%Y-%m-%d %H:%M:%S",
25-
)
27+
logger = logging.getLogger(__name__)
28+
29+
30+
@dataclass
31+
class DownloadConfig:
32+
directory: Path | None
33+
verbose: bool
34+
show_all_chapter_bars: bool
2635

2736

2837
def check_ffmpeg() -> None:
2938
"""Check if ffmpeg is available in PATH."""
3039
if not shutil.which("ffmpeg"):
31-
print("Error: ffmpeg is required but not found in PATH")
32-
print("Please install ffmpeg:")
33-
print(" macOS: brew install ffmpeg")
34-
print(" Ubuntu/Debian: sudo apt install ffmpeg")
35-
print(" Windows: Download from https://ffmpeg.org/download.html")
40+
logger.error("ffmpeg is required but not found in PATH")
41+
logger.info("Please install ffmpeg:")
42+
logger.info(" macOS: brew install ffmpeg")
43+
logger.info(" Ubuntu/Debian: sudo apt install ffmpeg")
44+
logger.info(" Windows: Download from https://ffmpeg.org/download.html")
3645
sys.exit(1)
3746

3847

@@ -41,77 +50,154 @@ def clear_terminal() -> None:
4150
system("cls" if name == "nt" else "clear") # noqa: S605
4251

4352

44-
def get_input():
45-
"""Get URL input from user for manual download."""
46-
url = input("Enter URL: ")
47-
53+
def validate_url(url: str) -> str:
54+
"""Validate and return a validated url."""
4855
if not url.startswith("https://tokybook.com/"):
49-
print("Invalid URL!")
50-
return get_input()
51-
56+
raise ValueError(f"Invalid URL: {url}. Must start with https://tokybook.com/")
5257
return url
5358

5459

55-
def main() -> None:
56-
"""Get argument from CLI and execute the selected action."""
57-
# Check for ffmpeg first
58-
check_ffmpeg()
59-
60+
def parse_arguments() -> argparse.Namespace:
61+
"""Parse command line arguments."""
6062
parser = argparse.ArgumentParser(
6163
description="TokySnatcher - Download Audiobooks from TokyBook"
6264
)
6365
parser.add_argument(
6466
"-d", "--directory", type=str, default=None, help="Custom download directory"
6567
)
6668
parser.add_argument(
67-
"-s", "--search", type=str, default=None, help="Search query to bypass interactive menu"
69+
"-s",
70+
"--search",
71+
type=str,
72+
default=None,
73+
help="Search query to bypass interactive menu",
6874
)
6975
parser.add_argument(
70-
"-u", "--url", type=str, default=None, help="Direct URL to download, bypassing search"
76+
"-u",
77+
"--url",
78+
type=str,
79+
default=None,
80+
help="Direct URL to download, bypassing search",
7181
)
72-
args = parser.parse_args()
82+
parser.add_argument(
83+
"-v",
84+
"--verbose",
85+
action="store_true",
86+
help="Show detailed logs during download",
87+
)
88+
parser.add_argument(
89+
"-a",
90+
"--show-all-chapter-bars",
91+
action="store_true",
92+
default=False,
93+
help="Show all chapter progress bars permanently",
94+
)
95+
return parser.parse_args()
7396

97+
98+
def execute_download(url: str, config: DownloadConfig) -> None:
99+
"""Execute the download process for a given URL."""
100+
from .utils import _shutdown_requested
101+
102+
logger.info("Download starting.")
103+
get_chapters(
104+
url,
105+
config.directory,
106+
verbose=config.verbose,
107+
show_all_chapter_bars=config.show_all_chapter_bars,
108+
)
109+
110+
# Check if download was interrupted
111+
if _shutdown_requested:
112+
logger.warning("Download was cancelled by user.")
113+
114+
115+
def handle_url_action(url: str, config: DownloadConfig) -> None:
116+
"""Handle direct URL download."""
117+
validated_url = validate_url(url)
118+
logger.info("Downloading from provided URL.")
119+
execute_download(validated_url, config)
120+
121+
122+
def handle_search_action(query: str, config: DownloadConfig) -> None:
123+
"""Handle search action."""
124+
result = search_book(query, interactive=True)
125+
if result:
126+
execute_download(result, config)
127+
128+
129+
def handle_interactive_action(config: DownloadConfig) -> None:
130+
"""Handle interactive menu selection."""
131+
choices = ["Search book", "Download from URL", "Exit"]
132+
selected_action = questionary.select("Choose action:", choices=choices).ask()
133+
134+
if selected_action is None:
135+
logger.info("No action selected. Exiting.")
136+
return
137+
138+
actions = {
139+
"Search book": lambda: handle_search_action(
140+
input("Enter search query: "), config
141+
),
142+
"Download from URL": lambda: handle_url_action(get_validated_input(), config),
143+
"Exit": lambda: None,
144+
}
145+
146+
action = actions.get(selected_action)
147+
if action:
148+
action()
149+
150+
151+
def get_validated_input() -> str:
152+
"""Get validated URL input from user."""
153+
url = input("Enter URL: ")
74154
try:
75-
# If URL provided, skip everything and download directly
76-
if args.url:
77-
logging.info("Downloading from provided URL.")
78-
get_chapters(args.url, args.directory)
79-
return
80-
81-
# If search query provided, skip interactive menu
82-
if args.search:
83-
result = search_book(args.search)
84-
if result:
85-
logging.info("Action completed successfully.")
86-
get_chapters(result, args.directory)
87-
return
88-
89-
# Define available choices
90-
choices = ["Search book", "Download from URL", "Exit"]
91-
selected_action = questionary.select("Choose action:", choices=choices).ask()
92-
93-
if selected_action is None:
94-
logging.info("No action selected. Exiting.")
95-
sys.exit(0)
96-
97-
# Map actions to corresponding functions
98-
actions = [search_book, get_input, sys.exit]
99-
action = actions[choices.index(selected_action)]
100-
result = action()
101-
102-
if result:
103-
logging.info("Action completed successfully.")
104-
get_chapters(result, args.directory)
155+
return validate_url(url)
156+
except ValueError as e:
157+
print(f"Error: {e}")
158+
return get_validated_input()
159+
160+
161+
def get_input() -> str:
162+
"""Get URL input from user for manual download. Deprecated, use get_validated_input."""
163+
while True:
164+
url = input("Enter URL: ")
165+
try:
166+
return validate_url(url)
167+
except ValueError as e:
168+
print(f"Error: {e}")
169+
continue
170+
105171

172+
def main() -> None:
173+
"""Get argument from CLI and execute the selected action."""
174+
check_ffmpeg()
175+
args = parse_arguments()
176+
setup_colored_logging(args.verbose)
177+
178+
config = DownloadConfig(
179+
directory=Path(args.directory) if args.directory else None,
180+
verbose=args.verbose,
181+
show_all_chapter_bars=args.show_all_chapter_bars,
182+
)
183+
184+
try:
185+
if args.url:
186+
handle_url_action(args.url, config)
187+
elif args.search:
188+
handle_search_action(args.search, config)
189+
else:
190+
handle_interactive_action(config)
106191
except KeyboardInterrupt:
107-
logging.warning("Process interrupted by user.")
192+
logger.warning("Process interrupted by user.")
108193
sys.exit(0)
109-
except Exception as e:
110-
logging.error(f"An unexpected error occurred: {e}")
194+
except Exception:
195+
logger.exception("An unexpected error occurred")
111196
sys.exit(1)
112197
finally:
113198
if termios:
114199
termios.tcsetattr(fd, termios.TCSADRAIN, old_term_settings)
115200

201+
116202
if __name__ == "__main__":
117203
main()

0 commit comments

Comments
 (0)