librofm 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.
- librofm-0.1.0/LICENSE +21 -0
- librofm-0.1.0/PKG-INFO +0 -0
- librofm-0.1.0/README.md +0 -0
- librofm-0.1.0/pyproject.toml +46 -0
- librofm-0.1.0/src/librofm/__init__.py +0 -0
- librofm-0.1.0/src/librofm/__main__.py +7 -0
- librofm-0.1.0/src/librofm/client.py +219 -0
- librofm-0.1.0/src/librofm/download.py +40 -0
- librofm-0.1.0/src/librofm/models.py +98 -0
- librofm-0.1.0/src/librofm/util.py +20 -0
librofm-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Bryan L. Fordham
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
librofm-0.1.0/PKG-INFO
ADDED
|
Binary file
|
librofm-0.1.0/README.md
ADDED
|
Binary file
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "librofm"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A Python client library for downloading audiobooks from Libro.fm"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Bryan L. Fordham", email = "bryan@nativesavannah.com"}
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"requests>=2.32.4,<3.0.0",
|
|
12
|
+
"pydantic>=2.11.5,<3.0.0",
|
|
13
|
+
"pydantic-settings>=2.9.1,<3.0.0"
|
|
14
|
+
]
|
|
15
|
+
license = {text = "MIT"}
|
|
16
|
+
keywords = ["audiobook", "libro.fm", "download", "cli"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Intended Audience :: End Users/Desktop",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
librofm-download = "librofm.download:main"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
"Homepage" = "https://codeberge.org/bfordham/librofm"
|
|
34
|
+
"Bug Reports" = "https://codeberg.org/bfordham/librofm/issues"
|
|
35
|
+
"Source" = "https://codeberg.org/bfordham/librofm"
|
|
36
|
+
|
|
37
|
+
[tool.poetry]
|
|
38
|
+
packages = [{include = "librofm", from = "src"}]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
[tool.poetry.group.dev.dependencies]
|
|
42
|
+
pytest = "^8.4.0"
|
|
43
|
+
|
|
44
|
+
[build-system]
|
|
45
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
46
|
+
build-backend = "poetry.core.masonry.api"
|
|
File without changes
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
|
|
2
|
+
import os
|
|
3
|
+
from urllib import parse
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from librofm.models import Audiobook, Credentials, LibroFMClientSettings, Manifest, PackagedM4b, Page
|
|
7
|
+
from librofm.util import get_isbn, requires_auth
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
BASE_URL = "https://libro.fm"
|
|
11
|
+
LOGIN_ENDPOINT = "/oauth/token"
|
|
12
|
+
LIBRARY_ENDPOINT = "/api/v7/library"
|
|
13
|
+
DOWNLOAD_ENDPOINT = "/api/v9/download-manifest"
|
|
14
|
+
PACKAGED_M4B_ENDPOINT = "/api/v10/audiobooks/{isbn}/packaged_m4b"
|
|
15
|
+
|
|
16
|
+
class LibroFMClient:
|
|
17
|
+
@staticmethod
|
|
18
|
+
def get_client():
|
|
19
|
+
settings = LibroFMClientSettings()
|
|
20
|
+
return LibroFMClient(settings.username, settings.password)
|
|
21
|
+
|
|
22
|
+
def __init__(self, username:str, password:str) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Initializes the LibroFM client with the provided username and password.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
username (str): The username for the LibroFM account.
|
|
28
|
+
password (str): The password for the LibroFM account.
|
|
29
|
+
"""
|
|
30
|
+
self.username = username
|
|
31
|
+
self.password = password
|
|
32
|
+
self.access_token = None
|
|
33
|
+
|
|
34
|
+
def authenticate(self) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Authenticates the user with the LibroFM API and retrieves an access token.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
Exception: If authentication fails.
|
|
40
|
+
"""
|
|
41
|
+
params = {
|
|
42
|
+
"grant_type": "password",
|
|
43
|
+
"username": self.username,
|
|
44
|
+
"password": self.password,
|
|
45
|
+
}
|
|
46
|
+
response = self._do_post(LOGIN_ENDPOINT, params)
|
|
47
|
+
token_data = Credentials(**response)
|
|
48
|
+
|
|
49
|
+
if not token_data.access_token:
|
|
50
|
+
raise Exception("Authentication failed. Please check your credentials.")
|
|
51
|
+
|
|
52
|
+
self.access_token = token_data.access_token
|
|
53
|
+
|
|
54
|
+
@requires_auth
|
|
55
|
+
def get_library(self, page: int = 1) -> Page:
|
|
56
|
+
"""
|
|
57
|
+
Retrieves the user's audiobook library from the LibroFM API.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
page (int): The page number to retrieve (default is 1).
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
dict: The response containing the user's audiobook library.
|
|
64
|
+
"""
|
|
65
|
+
params = {"page": page}
|
|
66
|
+
data = self._do_get(LIBRARY_ENDPOINT, params=params)
|
|
67
|
+
return Page(**data)
|
|
68
|
+
|
|
69
|
+
@requires_auth
|
|
70
|
+
def get_download_manifest(self, audiobook: Audiobook | str) -> Manifest:
|
|
71
|
+
"""
|
|
72
|
+
Retrieves the download manifest for a specific audiobook.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
audiobook (Audiobook): The audiobook for which to retrieve the download manifest.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
dict: The response containing the download manifest.
|
|
79
|
+
"""
|
|
80
|
+
isbn = get_isbn(audiobook)
|
|
81
|
+
params = {"isbn": isbn}
|
|
82
|
+
data = self._do_get(DOWNLOAD_ENDPOINT, params=params)
|
|
83
|
+
return Manifest(**data)
|
|
84
|
+
|
|
85
|
+
@requires_auth
|
|
86
|
+
def get_packaged_m4b_info(self, audiobook: Audiobook | str) -> PackagedM4b | None:
|
|
87
|
+
"""
|
|
88
|
+
Retrieves information about the packaged M4B for a specific audiobook.
|
|
89
|
+
Args:
|
|
90
|
+
audiobook (Audiobook): The audiobook for which to retrieve the packaged M4B information.
|
|
91
|
+
Returns:
|
|
92
|
+
PackagedM4b: The packaged M4B information for the audiobook.
|
|
93
|
+
"""
|
|
94
|
+
isbn = get_isbn(audiobook)
|
|
95
|
+
endpoint = PACKAGED_M4B_ENDPOINT.format(isbn=isbn)
|
|
96
|
+
data = self._do_get(endpoint)
|
|
97
|
+
if not data or "m4b_url" not in data:
|
|
98
|
+
return None
|
|
99
|
+
return PackagedM4b(**data)
|
|
100
|
+
|
|
101
|
+
@requires_auth
|
|
102
|
+
def download(self, audiobook: Audiobook | str, output_dir: str = ".") -> bool:
|
|
103
|
+
"""
|
|
104
|
+
Downloads the audiobook in either MP3 or M4B format based on availability.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
audiobook (Audiobook): The audiobook to download.
|
|
108
|
+
output_dir (str): The directory where the audiobook will be saved.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
bool: True if the download was successful, False otherwise.
|
|
112
|
+
"""
|
|
113
|
+
success = self.download_m4b(audiobook, output_dir)
|
|
114
|
+
if success:
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
return self.download_mp3(audiobook, output_dir)
|
|
118
|
+
|
|
119
|
+
def download_mp3(self, audiobook: Audiobook | str, output_dir: str = ".") -> bool:
|
|
120
|
+
"""
|
|
121
|
+
Downloads the audiobook by retrieving its download manifest.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
audiobook (Audiobook): The audiobook to download.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Manifest: The download manifest for the audiobook.
|
|
128
|
+
"""
|
|
129
|
+
manifest = self.get_download_manifest(audiobook)
|
|
130
|
+
if not manifest:
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
for part in manifest.parts:
|
|
134
|
+
part_url = part.url
|
|
135
|
+
response = requests.get(part_url, stream=True)
|
|
136
|
+
if response.status_code == 200:
|
|
137
|
+
parsed = parse.urlparse(part_url)
|
|
138
|
+
filename = parse.unquote(parsed.path.split('/')[-1])
|
|
139
|
+
outpath = f"{output_dir}/{filename}"
|
|
140
|
+
if os.path.exists(outpath):
|
|
141
|
+
return True
|
|
142
|
+
with open(outpath, "wb") as f:
|
|
143
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
144
|
+
f.write(chunk)
|
|
145
|
+
else:
|
|
146
|
+
print(f"Failed to download part: {part_url}")
|
|
147
|
+
return False
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
def download_m4b(self, audiobook: Audiobook | str, output_dir: str = ".") -> bool:
|
|
151
|
+
"""
|
|
152
|
+
Downloads the audiobook in M4B format by retrieving its packaged info.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
audiobook (Audiobook): The audiobook to download.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
PackagedM4B: The download manifest for the audiobook.
|
|
159
|
+
"""
|
|
160
|
+
packaged = self.get_packaged_m4b_info(audiobook)
|
|
161
|
+
if not packaged or not packaged.m4b_url:
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
response = requests.get(packaged.m4b_url, stream=True)
|
|
165
|
+
if response.status_code == 200:
|
|
166
|
+
parsed = parse.urlparse(packaged.m4b_url)
|
|
167
|
+
filename = parse.unquote(parsed.path.split('/')[-1])
|
|
168
|
+
outpath = f"{output_dir}/{filename}"
|
|
169
|
+
if os.path.exists(outpath):
|
|
170
|
+
return True
|
|
171
|
+
with open(outpath, "wb") as f:
|
|
172
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
173
|
+
f.write(chunk)
|
|
174
|
+
else:
|
|
175
|
+
print(f"Failed to download part: {packaged.m4b_url}")
|
|
176
|
+
return False
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
def _do_post(self, endpoint: str, data: dict) -> dict:
|
|
180
|
+
"""
|
|
181
|
+
Makes a POST request to the specified endpoint with the provided data.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
endpoint (str): The API endpoint to send the request to.
|
|
185
|
+
data (dict): The data to include in the POST request.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
dict: The response from the API.
|
|
189
|
+
"""
|
|
190
|
+
return requests.post(f"{BASE_URL}{endpoint}", headers=self.headers, json=data).json()
|
|
191
|
+
|
|
192
|
+
def _do_get(self, endpoint: str, params: dict = None) -> dict:
|
|
193
|
+
"""
|
|
194
|
+
Makes a GET request to the specified endpoint with the provided parameters.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
endpoint (str): The API endpoint to send the request to.
|
|
198
|
+
params (dict, optional): The parameters to include in the GET request.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
dict: The response from the API.
|
|
202
|
+
"""
|
|
203
|
+
return requests.get(f"{BASE_URL}{endpoint}", headers=self.headers, params=params).json()
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def headers(self) -> dict[str, str]:
|
|
207
|
+
"""
|
|
208
|
+
Returns the headers required for API requests, including the access token.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
dict[str, str]: The headers for the API request.
|
|
212
|
+
"""
|
|
213
|
+
h = {
|
|
214
|
+
"Content-Type": "application/json",
|
|
215
|
+
}
|
|
216
|
+
if self.access_token:
|
|
217
|
+
h["Authorization"] = f"Bearer {self.access_token}"
|
|
218
|
+
return h
|
|
219
|
+
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import sys
|
|
4
|
+
from librofm.client import LibroFMClient
|
|
5
|
+
from librofm.util import clean_filename
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
parser = argparse.ArgumentParser(description="Download audiobooks from Libro.fm")
|
|
10
|
+
parser.add_argument(
|
|
11
|
+
"base_path",
|
|
12
|
+
nargs="?",
|
|
13
|
+
default="~/Audiobooks",
|
|
14
|
+
help="Base path for audiobook downloads (default: ~/Audiobooks)"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
args = parser.parse_args()
|
|
18
|
+
|
|
19
|
+
c = LibroFMClient.get_client()
|
|
20
|
+
BASE_PATH = Path(args.base_path).expanduser()
|
|
21
|
+
|
|
22
|
+
page = c.get_library(page=1)
|
|
23
|
+
for idx, audiobook in enumerate(page.audiobooks):
|
|
24
|
+
authors = Path(clean_filename(', '.join(audiobook.authors)))
|
|
25
|
+
title = Path(clean_filename(audiobook.title))
|
|
26
|
+
path = BASE_PATH / authors / title
|
|
27
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
sys.stdout.write(f"\r⏳ Downloading {audiobook.title} {idx}")
|
|
29
|
+
sys.stdout.flush()
|
|
30
|
+
try:
|
|
31
|
+
success = c.download(audiobook, path)
|
|
32
|
+
if not success:
|
|
33
|
+
print(f"\rFailed to download {audiobook.title}")
|
|
34
|
+
except Exception as e:
|
|
35
|
+
print(f"\rError downloading {audiobook.title}: {e}")
|
|
36
|
+
print(f"\r✓ Downloaded {audiobook.title} {idx} ")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
main()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import TYPE_CHECKING, Optional
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
from pydantic_settings import BaseSettings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Credentials(BaseModel):
|
|
9
|
+
"""
|
|
10
|
+
Represents the credentials required to access the LibroFM API.
|
|
11
|
+
"""
|
|
12
|
+
access_token: str
|
|
13
|
+
token_type: str
|
|
14
|
+
created_at: int
|
|
15
|
+
|
|
16
|
+
class Audiobook(BaseModel):
|
|
17
|
+
"""
|
|
18
|
+
Represents an audiobook with its metadata.
|
|
19
|
+
"""
|
|
20
|
+
title: str
|
|
21
|
+
isbn: int
|
|
22
|
+
authors: list[str]
|
|
23
|
+
cover_url: str
|
|
24
|
+
catalog_info: dict
|
|
25
|
+
audiobook_info: dict
|
|
26
|
+
id: int
|
|
27
|
+
subtitle: str | None
|
|
28
|
+
publisher: str
|
|
29
|
+
publication_date: datetime
|
|
30
|
+
created_at: datetime
|
|
31
|
+
updated_at: datetime
|
|
32
|
+
description: str
|
|
33
|
+
genres: list[dict]
|
|
34
|
+
lead: str | None
|
|
35
|
+
abridged: bool
|
|
36
|
+
series: str | None
|
|
37
|
+
series_num: int | None
|
|
38
|
+
recommendations: list[dict]
|
|
39
|
+
user_metadata: dict | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Page(BaseModel):
|
|
43
|
+
"""
|
|
44
|
+
Represents pagination information for API responses.
|
|
45
|
+
"""
|
|
46
|
+
page: int
|
|
47
|
+
total_pages: int
|
|
48
|
+
audiobooks: list[Audiobook]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ManifestPart(BaseModel):
|
|
52
|
+
"""
|
|
53
|
+
Represents a part of an audiobook, including its URL and size in bytes.
|
|
54
|
+
"""
|
|
55
|
+
url: str
|
|
56
|
+
size_bytes: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ManifestTrack(BaseModel):
|
|
60
|
+
"""
|
|
61
|
+
Represents a track of an audiobook, including its number, length, chapter title, and timestamps.
|
|
62
|
+
"""
|
|
63
|
+
number: int
|
|
64
|
+
length_sec: int
|
|
65
|
+
chapter_title: str | None
|
|
66
|
+
created_at: datetime
|
|
67
|
+
updated_at: datetime
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Manifest(BaseModel):
|
|
71
|
+
"""
|
|
72
|
+
Represents metadata for an audiobook, including its parts and tracks.
|
|
73
|
+
"""
|
|
74
|
+
isbn: int
|
|
75
|
+
parts: list[ManifestPart]
|
|
76
|
+
tracks: list[ManifestTrack]
|
|
77
|
+
expires_at: datetime
|
|
78
|
+
version: int
|
|
79
|
+
size_bytes: int
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class PackagedM4b(BaseModel):
|
|
83
|
+
"""
|
|
84
|
+
Represents a packaged M4B file, including its URL and size in bytes.
|
|
85
|
+
"""
|
|
86
|
+
m4b_url: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class LibroFMClientSettings(BaseSettings):
|
|
90
|
+
"""
|
|
91
|
+
Settings for the LibroFM client, including API credentials and endpoints.
|
|
92
|
+
"""
|
|
93
|
+
username: str
|
|
94
|
+
password: str
|
|
95
|
+
|
|
96
|
+
class Config:
|
|
97
|
+
env_file = ".env"
|
|
98
|
+
env_prefix = "LIBROFM_"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
|
|
3
|
+
from librofm.models import Audiobook
|
|
4
|
+
|
|
5
|
+
def requires_auth(func):
|
|
6
|
+
"""Decorator that ensures authentication before method execution."""
|
|
7
|
+
@wraps(func)
|
|
8
|
+
def wrapper(self, *args, **kwargs):
|
|
9
|
+
if not self.access_token:
|
|
10
|
+
self.authenticate()
|
|
11
|
+
return func(self, *args, **kwargs)
|
|
12
|
+
return wrapper
|
|
13
|
+
|
|
14
|
+
def get_isbn(audiobook: Audiobook | str) -> str:
|
|
15
|
+
"""Returns the ISBN of the audiobook, or the audiobook itself if it's a string."""
|
|
16
|
+
return audiobook.isbn if isinstance(audiobook, Audiobook) else audiobook
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def clean_filename(name: str) -> str:
|
|
20
|
+
return "".join(c for c in name if c.isalnum() or c in (' ', '-', '_', '(', ')', '.', ',')).rstrip()
|