storycli 0.1.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,92 @@
1
+ Metadata-Version: 2.3
2
+ Name: storycli
3
+ Version: 0.1.0
4
+ Summary: StoryCLI: CLI tool for downloading audiobooks from Storytel.
5
+ Author: degD
6
+ Author-email: degD <degd@degd.net>
7
+ Requires-Dist: cryptography>=50.0.1
8
+ Requires-Dist: python-dotenv>=1.2.3
9
+ Requires-Dist: requests>=2.34.2
10
+ Requires-Dist: tqdm>=4.70.0
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+
14
+
15
+ # StoryCLI
16
+
17
+ StoryCLI is an unofficial CLI tool for downloading your favourite audiobooks
18
+ from Storytel without leaving your terminal. An active Storytel account is
19
+ required for using this tool.
20
+
21
+ I am not responsible for any issues that this tool could cause, including
22
+ but not limited to account blocking, temporary or permanent bans, loss of
23
+ time or monet. I built this tool only as a learning experience, and making
24
+ it open-source so other people can see the source code and learn. Use it
25
+ at your own risk.
26
+
27
+ - Download from PyPI: `pipx install storycli`
28
+
29
+ ## Authentication
30
+
31
+ Create `~/.storycli` and fill it with your login credentials:
32
+
33
+ ```
34
+ STORYCLI_MAIL=<your-storytel-email>
35
+ STORYCLI_PASS=<your-storytel-password>
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```
41
+ usage: storycli [-h] (-q QUERY | -D BOOK_ID) [-o PATH] [-H]
42
+
43
+ Search and download Storytel audiobooks.
44
+
45
+ options:
46
+ -h, --help show this help message and exit
47
+ -q QUERY, --query QUERY
48
+ search for audiobooks
49
+ -D BOOK_ID, --download BOOK_ID
50
+ download an audiobook
51
+ -o PATH, --output PATH
52
+ MP3 output path (required with --download)
53
+ -H, --human-readable do not format query lengths as hours:minutes:seconds
54
+
55
+ ```
56
+
57
+ **Search an Audiobook:**
58
+
59
+ storycli -q <search-query>
60
+ storycli -q Dune
61
+ storycli --query Dune
62
+
63
+ ID LENGTH TITLE
64
+ ------- -------- ---------------
65
+ ...
66
+ 2759046 18:36:1 Paul of Dune
67
+ 2836812 17:19:18 The Winds of Dune
68
+ 2835170 19:31:19 Sandworms of Dune
69
+ ...
70
+
71
+ **Download an Audiobook:**
72
+
73
+ storycli -D <book-id> -o <mp3-save-path>
74
+ storycli -D 2759046 -o ~/Downloads/2759046.mp3
75
+ storycli --download 2759046 -o ~/Downloads/2759046.mp3
76
+
77
+ ## Development
78
+
79
+ * Install the `uv` project manager.
80
+ * Clone the project and run `uv sync`.
81
+
82
+ ## Future Improvements
83
+
84
+ * Prevent logging in everytime by storing login tokens.
85
+ * Add book description preview.
86
+ * Better UX.
87
+ * Standalone Storytel TUI with builtin player.
88
+ * Better error message management.
89
+
90
+ ## License
91
+
92
+ MIT
@@ -0,0 +1,79 @@
1
+
2
+ # StoryCLI
3
+
4
+ StoryCLI is an unofficial CLI tool for downloading your favourite audiobooks
5
+ from Storytel without leaving your terminal. An active Storytel account is
6
+ required for using this tool.
7
+
8
+ I am not responsible for any issues that this tool could cause, including
9
+ but not limited to account blocking, temporary or permanent bans, loss of
10
+ time or monet. I built this tool only as a learning experience, and making
11
+ it open-source so other people can see the source code and learn. Use it
12
+ at your own risk.
13
+
14
+ - Download from PyPI: `pipx install storycli`
15
+
16
+ ## Authentication
17
+
18
+ Create `~/.storycli` and fill it with your login credentials:
19
+
20
+ ```
21
+ STORYCLI_MAIL=<your-storytel-email>
22
+ STORYCLI_PASS=<your-storytel-password>
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```
28
+ usage: storycli [-h] (-q QUERY | -D BOOK_ID) [-o PATH] [-H]
29
+
30
+ Search and download Storytel audiobooks.
31
+
32
+ options:
33
+ -h, --help show this help message and exit
34
+ -q QUERY, --query QUERY
35
+ search for audiobooks
36
+ -D BOOK_ID, --download BOOK_ID
37
+ download an audiobook
38
+ -o PATH, --output PATH
39
+ MP3 output path (required with --download)
40
+ -H, --human-readable do not format query lengths as hours:minutes:seconds
41
+
42
+ ```
43
+
44
+ **Search an Audiobook:**
45
+
46
+ storycli -q <search-query>
47
+ storycli -q Dune
48
+ storycli --query Dune
49
+
50
+ ID LENGTH TITLE
51
+ ------- -------- ---------------
52
+ ...
53
+ 2759046 18:36:1 Paul of Dune
54
+ 2836812 17:19:18 The Winds of Dune
55
+ 2835170 19:31:19 Sandworms of Dune
56
+ ...
57
+
58
+ **Download an Audiobook:**
59
+
60
+ storycli -D <book-id> -o <mp3-save-path>
61
+ storycli -D 2759046 -o ~/Downloads/2759046.mp3
62
+ storycli --download 2759046 -o ~/Downloads/2759046.mp3
63
+
64
+ ## Development
65
+
66
+ * Install the `uv` project manager.
67
+ * Clone the project and run `uv sync`.
68
+
69
+ ## Future Improvements
70
+
71
+ * Prevent logging in everytime by storing login tokens.
72
+ * Add book description preview.
73
+ * Better UX.
74
+ * Standalone Storytel TUI with builtin player.
75
+ * Better error message management.
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "storycli"
3
+ version = "0.1.0"
4
+ description = "StoryCLI: CLI tool for downloading audiobooks from Storytel."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "cryptography>=50.0.1",
9
+ "python-dotenv>=1.2.3",
10
+ "requests>=2.34.2",
11
+ "tqdm>=4.70.0",
12
+ ]
13
+
14
+ [[project.authors]]
15
+ name = "degD"
16
+ email = "degd@degd.net"
17
+
18
+ [project.scripts]
19
+ storycli = "storycli:main"
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.12.9,<0.13.0"]
23
+ build-backend = "uv_build"
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "storycli"
3
+ version = "0.1.0"
4
+ description = "StoryCLI: CLI tool for downloading audiobooks from Storytel."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "degD", email = "degd@degd.net" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "cryptography>=50.0.1",
12
+ "python-dotenv>=1.2.3",
13
+ "requests>=2.34.2",
14
+ "tqdm>=4.70.0",
15
+ ]
16
+
17
+ [project.scripts]
18
+ storycli = "storycli:main"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.12.9,<0.13.0"]
22
+ build-backend = "uv_build"
@@ -0,0 +1 @@
1
+ from .main import main
@@ -0,0 +1,231 @@
1
+
2
+ """
3
+ StoryCLI: CLI tool for downloading audiobooks from Storytel. Requires an active Storytel
4
+ account. To use this tool, create the file "~/.storycli" and put your login credentials
5
+ here.
6
+ """
7
+
8
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
9
+ from cryptography.hazmat.primitives import padding
10
+ from pathlib import Path
11
+ from tqdm import tqdm
12
+ import argparse
13
+ import dotenv
14
+ import os
15
+ import requests
16
+ import sys
17
+
18
+
19
+ class LoginInfo:
20
+ """Manage login credentials and required password hashing."""
21
+
22
+ KEY = b"VQZBJ6TD8M9WBUWT"
23
+ IV = b"joiwef08u23j341a"
24
+
25
+ def __init__(self, email: str, password: str):
26
+ self.email = email
27
+ self.password = self.__hashPassword(password)
28
+
29
+ def __hashPassword(self, password: str) -> str:
30
+ padder = padding.PKCS7(algorithms.AES.block_size).padder() # type: ignore
31
+ padded_password = padder.update(password.encode("utf-8"))
32
+ padded_password += padder.finalize()
33
+ cipher = Cipher(algorithms.AES(LoginInfo.KEY), modes.CBC(LoginInfo.IV))
34
+ encryptor = cipher.encryptor()
35
+ ciphertext = encryptor.update(padded_password)
36
+ ciphertext += encryptor.finalize()
37
+ return ciphertext.hex().upper()
38
+
39
+
40
+ class AccountInfo:
41
+ """Helper class for managing tokens."""
42
+
43
+ def __init__(self, jwt: str, sst: str):
44
+ self.jwt = jwt
45
+ self.sst = sst
46
+
47
+
48
+ class BookData:
49
+ """Helper class for managing and representing book objects."""
50
+
51
+ def __init__(self, id: int, title: str, authors: str, length: int, description: str):
52
+ self.id = id
53
+ self.title = title
54
+ self.authors = authors
55
+ self.length = length
56
+ self.description = description
57
+
58
+
59
+ class StoryAPI:
60
+ """Manage access to Storytel API endpoints."""
61
+
62
+ CHUNK_SIZE = 1048576 # 1 MB
63
+
64
+ def __init__(self):
65
+ self.accountInfo: AccountInfo | None = None
66
+
67
+ def login(self, loginInfo: LoginInfo) -> bool:
68
+ """
69
+ Login to Storytel using given `LoginInfo` object.
70
+ Returns True if login successful. Returns False if something fails.
71
+ """
72
+ url = f"https://www.storytel.com/api/login.action?m=1&uid={loginInfo.email.strip()}&pwd={loginInfo.password}"
73
+ try:
74
+ response = requests.get(url)
75
+ if response.status_code == 200:
76
+ data = response.json()
77
+ self.accountInfo = AccountInfo(
78
+ data["accountInfo"]["jwt"],
79
+ data["accountInfo"]["singleSignToken"],
80
+ )
81
+ return True
82
+ except (requests.RequestException, KeyError, ValueError):
83
+ pass
84
+ return False
85
+
86
+ def searchBook(self, query: str) -> tuple[bool, list[BookData]]:
87
+ """
88
+ Search by given query. Returns a tuple with a bool representing status
89
+ and a list of `BookData` objects. Doesn't work if not logged in.
90
+ Bool is True if everything is successful. False otherwise.
91
+ """
92
+ if self.accountInfo is None:
93
+ return (False, [])
94
+ try:
95
+ url = f"https://www.storytel.com/api/search.action?q={query}&token={self.accountInfo.sst}"
96
+ response = requests.get(url)
97
+ if response.status_code != 200:
98
+ return (False, [])
99
+ books = [book for book in response.json()["books"] if book["abook"] is not None]
100
+ return (True, [
101
+ BookData(
102
+ book["abook"]["id"],
103
+ book["book"]["name"],
104
+ book["book"]["authorsAsString"],
105
+ book["abook"]["length"],
106
+ book["abook"]["description"],
107
+ )
108
+ for book in books
109
+ ])
110
+ except (requests.RequestException, KeyError, TypeError, ValueError):
111
+ return (False, [])
112
+
113
+ def downloadBook(self, bookId: int, path: Path) -> bool:
114
+ """
115
+ Download the audiobook represented by the `bookId` to `path`.
116
+ Returns False if something fails or not logged in. True if
117
+ successful.
118
+ """
119
+ if self.accountInfo is None:
120
+ return False
121
+ url = f"https://www.storytel.com/mp3streamRangeReq?startposition=0&programId={bookId}&token={self.accountInfo.sst}"
122
+ try:
123
+ response = requests.get(url, stream=True)
124
+ if response.status_code == 200:
125
+ with open(path, "wb") as file:
126
+ total_size = int(response.headers.get("content-length", 0)) or None
127
+ with tqdm(total=total_size, unit="B", unit_scale=True, desc="Downloading") as progress:
128
+ for chunk in response.iter_content(chunk_size=self.CHUNK_SIZE):
129
+ if chunk:
130
+ file.write(chunk)
131
+ progress.update(len(chunk))
132
+ return True
133
+ except (OSError, TypeError, ValueError, requests.RequestException):
134
+ pass
135
+ return False
136
+
137
+
138
+ class StoryCLI:
139
+ """Represents the CLI command/tool."""
140
+
141
+ LOGIN_FILE_PATH = Path("~/.storycli")
142
+
143
+ def __init__(self):
144
+ self.api = StoryAPI()
145
+ login_info = self.__loadLoginInfo()
146
+
147
+ # self.status represents the status of the login procedure.
148
+ # 0: LoginInfo loaded and logged in correctly.
149
+ # 1: Unable to load LoginInfo. File could be missing.
150
+ # 2: Login failed. Credentials could be wrong.
151
+ self.status = 0
152
+ if login_info is None:
153
+ self.status = 1
154
+ elif not self.api.login(login_info):
155
+ self.status = 2
156
+
157
+ def __loadLoginInfo(self) -> LoginInfo | None:
158
+ """
159
+ Try loading the `LOGIN_FILE_PATH` file, which contains credentials
160
+ in `.env` file format. If successful, return `LoginInfo`. Return
161
+ None otherwise.
162
+ """
163
+ dotenv.load_dotenv(self.LOGIN_FILE_PATH.expanduser())
164
+ email = os.getenv("STORYCLI_MAIL")
165
+ password = os.getenv("STORYCLI_PASS")
166
+ if email is not None and password is not None:
167
+ return LoginInfo(email, password)
168
+ return None
169
+
170
+ def search(self, query: str) -> tuple[bool, list[BookData]]:
171
+ """
172
+ Search by given query. Returns a tuple with a bool representing status
173
+ and a list of `BookData` objects. Doesn't work if not logged in.
174
+ Bool is True if everything is successful. False otherwise.
175
+ """
176
+ return self.api.searchBook(query) if self.status == 0 else (False, [])
177
+
178
+ def download(self, bookId: int, path: Path) -> bool:
179
+ """
180
+ Download the audiobook represented by the `bookId` to `path`.
181
+ Returns False if something fails or not logged in. True if
182
+ successful.
183
+ """
184
+ return self.status == 0 and self.api.downloadBook(bookId, path)
185
+
186
+
187
+ def main(argv: list[str] | None = None) -> int:
188
+ """Main CLI command function."""
189
+
190
+ def format_length(length: int) -> str:
191
+ """Return milliseconds `length` in human-readable format."""
192
+ seconds = length // 1000
193
+ hours = seconds // 3600
194
+ minutes = (seconds % 3600) // 60
195
+ return f"{hours}:{minutes}:{seconds % 60}"
196
+
197
+ parser = argparse.ArgumentParser(description="Search and download Storytel audiobooks.")
198
+ operations = parser.add_mutually_exclusive_group(required=True)
199
+ operations.add_argument("-q", "--query", metavar="QUERY", help="search for audiobooks")
200
+ operations.add_argument("-D", "--download", type=int, metavar="BOOK_ID", help="download an audiobook")
201
+ parser.add_argument("-o", "--output", type=Path, metavar="PATH", help="MP3 output path (required with --download)")
202
+ parser.add_argument("-H", "--human-readable", action="store_true", help="do not format query lengths as hours:minutes:seconds")
203
+ args = parser.parse_args(argv)
204
+
205
+ if args.download is not None and args.output is None:
206
+ parser.error("--output is required with --download")
207
+ if args.download is None and args.output is not None:
208
+ parser.error("--output may only be used with --download")
209
+
210
+ cli = StoryCLI()
211
+ if cli.status == 1:
212
+ print("error: credentials missing; set STORYCLI_MAIL and STORYCLI_PASS in ~/.storycli", file=sys.stderr)
213
+ return 1
214
+ if cli.status == 2:
215
+ print("error: login failed; check the credentials in ~/.storycli", file=sys.stderr)
216
+ return 2
217
+
218
+ if args.query is not None:
219
+ success, books = cli.search(args.query)
220
+ if not success:
221
+ print("error: search failed", file=sys.stderr)
222
+ return 3
223
+ for book in books:
224
+ length = format_length(book.length) if not args.human_readable else str(book.length)
225
+ print(f"{str(book.id).rjust(10)} {length.rjust(8)} {book.title}")
226
+ return 0
227
+
228
+ if cli.download(args.download, args.output): # type: ignore
229
+ return 0
230
+ print(f"error: download failed for book {args.download}", file=sys.stderr)
231
+ return 3