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.
- storycli-0.1.0/PKG-INFO +92 -0
- storycli-0.1.0/README.md +79 -0
- storycli-0.1.0/pyproject.toml +23 -0
- storycli-0.1.0/pyproject.toml.orig +22 -0
- storycli-0.1.0/src/storycli/__init__.py +1 -0
- storycli-0.1.0/src/storycli/main.py +231 -0
storycli-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
storycli-0.1.0/README.md
ADDED
|
@@ -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
|