shelfinventory 0.1.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.
- shelfinventory/__init__.py +8 -0
- shelfinventory/api.py +130 -0
- shelfinventory/cli.py +257 -0
- shelfinventory/rows.py +203 -0
- shelfinventory-0.1.0.dist-info/METADATA +130 -0
- shelfinventory-0.1.0.dist-info/RECORD +10 -0
- shelfinventory-0.1.0.dist-info/WHEEL +5 -0
- shelfinventory-0.1.0.dist-info/entry_points.txt +2 -0
- shelfinventory-0.1.0.dist-info/licenses/LICENSE +21 -0
- shelfinventory-0.1.0.dist-info/top_level.txt +1 -0
shelfinventory/api.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Minimal read-only client for the Audiobookshelf HTTP API.
|
|
2
|
+
|
|
3
|
+
Only GET requests exist here. There is no method that writes, patches or deletes
|
|
4
|
+
anything on the server, by design.
|
|
5
|
+
|
|
6
|
+
Endpoints used, with the upstream source that documents their shape:
|
|
7
|
+
|
|
8
|
+
* ``GET /api/libraries`` returns ``{"libraries": [...]}``
|
|
9
|
+
(``server/controllers/LibraryController.js``).
|
|
10
|
+
* ``GET /api/libraries/{id}/items?limit=&page=`` returns
|
|
11
|
+
``{"results": [...], "total": N, "limit": N, "page": N}`` and each entry is the
|
|
12
|
+
minified library item JSON (``server/models/LibraryItem.js``,
|
|
13
|
+
``toOldJSONMinified``).
|
|
14
|
+
* ``GET /api/items/{id}`` returns the expanded library item JSON, which is the
|
|
15
|
+
only place where series sequences and author lists are separate fields
|
|
16
|
+
(``server/models/Book.js``, ``oldMetadataToJSON``).
|
|
17
|
+
|
|
18
|
+
Authentication is a bearer token in the ``Authorization`` header
|
|
19
|
+
(``server/Auth.js``, ``fromAuthHeaderAsBearerToken``).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.parse
|
|
27
|
+
import urllib.request
|
|
28
|
+
|
|
29
|
+
DEFAULT_PAGE_SIZE = 500
|
|
30
|
+
DEFAULT_TIMEOUT = 30.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ApiError(Exception):
|
|
34
|
+
"""Any failure while talking to the server, with a readable message."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _default_opener(request, timeout):
|
|
38
|
+
return urllib.request.urlopen(request, timeout=timeout)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Client:
|
|
42
|
+
"""A read-only Audiobookshelf client.
|
|
43
|
+
|
|
44
|
+
``opener`` exists so the tests can answer requests from sample payloads
|
|
45
|
+
instead of reaching a server. It takes ``(request, timeout)`` and returns a
|
|
46
|
+
file-like object, exactly like :func:`urllib.request.urlopen`.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, base_url, token, timeout=DEFAULT_TIMEOUT, opener=None):
|
|
50
|
+
if not base_url:
|
|
51
|
+
raise ApiError("no server address was given")
|
|
52
|
+
if not token:
|
|
53
|
+
raise ApiError("no API token was given")
|
|
54
|
+
self.base_url = base_url.rstrip("/")
|
|
55
|
+
self.token = token
|
|
56
|
+
self.timeout = timeout
|
|
57
|
+
self._opener = opener or _default_opener
|
|
58
|
+
|
|
59
|
+
def get_json(self, path, params=None):
|
|
60
|
+
url = self.base_url + path
|
|
61
|
+
if params:
|
|
62
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
63
|
+
request = urllib.request.Request(
|
|
64
|
+
url,
|
|
65
|
+
method="GET",
|
|
66
|
+
headers={
|
|
67
|
+
"Authorization": "Bearer " + self.token,
|
|
68
|
+
"Accept": "application/json",
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
try:
|
|
72
|
+
response = self._opener(request, self.timeout)
|
|
73
|
+
except urllib.error.HTTPError as error:
|
|
74
|
+
raise ApiError(_http_message(error, url)) from error
|
|
75
|
+
except urllib.error.URLError as error:
|
|
76
|
+
raise ApiError("could not reach %s: %s" % (url, error.reason)) from error
|
|
77
|
+
with response:
|
|
78
|
+
raw = response.read()
|
|
79
|
+
try:
|
|
80
|
+
return json.loads(raw.decode("utf-8"))
|
|
81
|
+
except (UnicodeDecodeError, ValueError) as error:
|
|
82
|
+
raise ApiError("%s did not answer with JSON" % url) from error
|
|
83
|
+
|
|
84
|
+
def libraries(self):
|
|
85
|
+
"""Every library the token can see, as a list of dicts."""
|
|
86
|
+
payload = self.get_json("/api/libraries")
|
|
87
|
+
libraries = payload.get("libraries")
|
|
88
|
+
if not isinstance(libraries, list):
|
|
89
|
+
raise ApiError("the server did not return a list of libraries")
|
|
90
|
+
return libraries
|
|
91
|
+
|
|
92
|
+
def library_items(self, library_id, page_size=DEFAULT_PAGE_SIZE):
|
|
93
|
+
"""Yield every library item of one library, walking the pages."""
|
|
94
|
+
if page_size < 1:
|
|
95
|
+
raise ApiError("the page size must be at least 1")
|
|
96
|
+
page = 0
|
|
97
|
+
seen = 0
|
|
98
|
+
while True:
|
|
99
|
+
payload = self.get_json(
|
|
100
|
+
"/api/libraries/%s/items" % urllib.parse.quote(str(library_id)),
|
|
101
|
+
{"limit": page_size, "page": page},
|
|
102
|
+
)
|
|
103
|
+
results = payload.get("results")
|
|
104
|
+
if not isinstance(results, list):
|
|
105
|
+
raise ApiError("the server did not return a list of items")
|
|
106
|
+
if not results:
|
|
107
|
+
return
|
|
108
|
+
for entry in results:
|
|
109
|
+
yield entry
|
|
110
|
+
seen += len(results)
|
|
111
|
+
total = payload.get("total")
|
|
112
|
+
if isinstance(total, int) and seen >= total:
|
|
113
|
+
return
|
|
114
|
+
if len(results) < page_size:
|
|
115
|
+
return
|
|
116
|
+
page += 1
|
|
117
|
+
|
|
118
|
+
def item(self, item_id):
|
|
119
|
+
"""One library item in its expanded form."""
|
|
120
|
+
return self.get_json("/api/items/%s" % urllib.parse.quote(str(item_id)))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _http_message(error, url):
|
|
124
|
+
if error.code == 401:
|
|
125
|
+
return "the server rejected the token (401) for %s" % url
|
|
126
|
+
if error.code == 403:
|
|
127
|
+
return "the token is not allowed to read %s (403)" % url
|
|
128
|
+
if error.code == 404:
|
|
129
|
+
return "%s does not exist on this server (404)" % url
|
|
130
|
+
return "%s answered %s %s" % (url, error.code, error.reason)
|
shelfinventory/cli.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Command line entry point for shelfinventory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import csv
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .api import DEFAULT_PAGE_SIZE, DEFAULT_TIMEOUT, ApiError, Client
|
|
12
|
+
from .rows import COLUMNS, apply_exact_fields, count_filled, row_from_item
|
|
13
|
+
|
|
14
|
+
TOKEN_ENV = "AUDIOBOOKSHELF_TOKEN"
|
|
15
|
+
URL_ENV = "AUDIOBOOKSHELF_URL"
|
|
16
|
+
|
|
17
|
+
DELIMITER_NAMES = {
|
|
18
|
+
"comma": ",",
|
|
19
|
+
"semicolon": ";",
|
|
20
|
+
"tab": "\t",
|
|
21
|
+
"pipe": "|",
|
|
22
|
+
"colon": ":",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_delimiter(text):
|
|
27
|
+
"""A delimiter given either by name or as the character itself."""
|
|
28
|
+
if text is None:
|
|
29
|
+
return ","
|
|
30
|
+
lowered = text.lower()
|
|
31
|
+
if lowered in DELIMITER_NAMES:
|
|
32
|
+
return DELIMITER_NAMES[lowered]
|
|
33
|
+
if text == "\\t":
|
|
34
|
+
return "\t"
|
|
35
|
+
if len(text) == 1:
|
|
36
|
+
return text
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"a delimiter is one character, or one of: %s"
|
|
39
|
+
% ", ".join(sorted(DELIMITER_NAMES))
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def select_libraries(libraries, wanted):
|
|
44
|
+
"""Pick the libraries to export, by name or id.
|
|
45
|
+
|
|
46
|
+
With no name given, every book library is exported. Podcast libraries are
|
|
47
|
+
never exported: the columns asked for are book columns.
|
|
48
|
+
"""
|
|
49
|
+
books = [
|
|
50
|
+
library
|
|
51
|
+
for library in libraries
|
|
52
|
+
if library.get("mediaType", "book") == "book"
|
|
53
|
+
]
|
|
54
|
+
if not wanted:
|
|
55
|
+
return books
|
|
56
|
+
by_key = {}
|
|
57
|
+
for library in books:
|
|
58
|
+
by_key[str(library.get("name", "")).lower()] = library
|
|
59
|
+
by_key[str(library.get("id", "")).lower()] = library
|
|
60
|
+
chosen = []
|
|
61
|
+
missing = []
|
|
62
|
+
for name in wanted:
|
|
63
|
+
library = by_key.get(name.lower())
|
|
64
|
+
if library is None:
|
|
65
|
+
missing.append(name)
|
|
66
|
+
elif library not in chosen:
|
|
67
|
+
chosen.append(library)
|
|
68
|
+
if missing:
|
|
69
|
+
known = ", ".join(str(library.get("name")) for library in books) or "none"
|
|
70
|
+
raise ApiError(
|
|
71
|
+
"no book library named %s; book libraries on this server: %s"
|
|
72
|
+
% (", ".join(missing), known)
|
|
73
|
+
)
|
|
74
|
+
return chosen
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_rows(client, libraries, page_size=DEFAULT_PAGE_SIZE,
|
|
78
|
+
duration_style="hms", exact=False):
|
|
79
|
+
"""Every row of every given library, in the order the server returns them."""
|
|
80
|
+
rows = []
|
|
81
|
+
for library in libraries:
|
|
82
|
+
library_name = str(library.get("name", ""))
|
|
83
|
+
library_id = library.get("id")
|
|
84
|
+
for item in client.library_items(library_id, page_size=page_size):
|
|
85
|
+
row = row_from_item(item, library_name, duration_style)
|
|
86
|
+
if exact:
|
|
87
|
+
item_id = item.get("id")
|
|
88
|
+
if item_id:
|
|
89
|
+
apply_exact_fields(row, client.item(item_id))
|
|
90
|
+
rows.append(row)
|
|
91
|
+
return rows
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write_csv(rows, stream, delimiter=","):
|
|
95
|
+
writer = csv.DictWriter(
|
|
96
|
+
stream, fieldnames=list(COLUMNS), delimiter=delimiter, lineterminator="\n"
|
|
97
|
+
)
|
|
98
|
+
writer.writeheader()
|
|
99
|
+
for row in rows:
|
|
100
|
+
writer.writerow(row)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def coverage_report(rows):
|
|
104
|
+
"""Lines saying which columns the server actually filled."""
|
|
105
|
+
total = len(rows)
|
|
106
|
+
lines = ["%d book(s) exported, %d columns." % (total, len(COLUMNS))]
|
|
107
|
+
if not total:
|
|
108
|
+
return lines
|
|
109
|
+
counts = count_filled(rows)
|
|
110
|
+
empty = [name for name in COLUMNS if counts[name] == 0]
|
|
111
|
+
partial = [
|
|
112
|
+
"%s (%d/%d)" % (name, counts[name], total)
|
|
113
|
+
for name in COLUMNS
|
|
114
|
+
if 0 < counts[name] < total
|
|
115
|
+
]
|
|
116
|
+
if empty:
|
|
117
|
+
lines.append("Empty for every book: %s." % ", ".join(empty))
|
|
118
|
+
else:
|
|
119
|
+
lines.append("Every column had a value on at least one book.")
|
|
120
|
+
if partial:
|
|
121
|
+
lines.append("Filled on some books only: %s." % ", ".join(partial))
|
|
122
|
+
return lines
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def build_parser():
|
|
126
|
+
parser = argparse.ArgumentParser(
|
|
127
|
+
prog="shelfinventory",
|
|
128
|
+
description=(
|
|
129
|
+
"Export an Audiobookshelf library to CSV, one row per book. "
|
|
130
|
+
"Reads over the HTTP API and writes nothing to the server."
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
parser.add_argument(
|
|
134
|
+
"--url",
|
|
135
|
+
default=os.environ.get(URL_ENV),
|
|
136
|
+
help="server address, for example https://books.example.com (or %s)" % URL_ENV,
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--library",
|
|
140
|
+
action="append",
|
|
141
|
+
metavar="NAME",
|
|
142
|
+
help="library name or id; repeat for several; default is every book library",
|
|
143
|
+
)
|
|
144
|
+
parser.add_argument(
|
|
145
|
+
"--out", metavar="FILE", help="write the CSV here instead of standard output"
|
|
146
|
+
)
|
|
147
|
+
parser.add_argument(
|
|
148
|
+
"--delimiter",
|
|
149
|
+
default=",",
|
|
150
|
+
help="field delimiter: one character, or comma, semicolon, tab, pipe, colon",
|
|
151
|
+
)
|
|
152
|
+
parser.add_argument(
|
|
153
|
+
"--duration",
|
|
154
|
+
choices=("hms", "seconds"),
|
|
155
|
+
default="hms",
|
|
156
|
+
help="duration as H:MM:SS (default) or as a number of seconds",
|
|
157
|
+
)
|
|
158
|
+
parser.add_argument(
|
|
159
|
+
"--exact",
|
|
160
|
+
action="store_true",
|
|
161
|
+
help=(
|
|
162
|
+
"read each book a second time to get exact author, narrator and "
|
|
163
|
+
"series fields instead of splitting the joined ones; one extra "
|
|
164
|
+
"request per book"
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
parser.add_argument(
|
|
168
|
+
"--page-size",
|
|
169
|
+
type=int,
|
|
170
|
+
default=DEFAULT_PAGE_SIZE,
|
|
171
|
+
metavar="N",
|
|
172
|
+
help="books per request (default %d)" % DEFAULT_PAGE_SIZE,
|
|
173
|
+
)
|
|
174
|
+
parser.add_argument(
|
|
175
|
+
"--timeout",
|
|
176
|
+
type=float,
|
|
177
|
+
default=DEFAULT_TIMEOUT,
|
|
178
|
+
metavar="SECONDS",
|
|
179
|
+
help="request timeout in seconds (default %g)" % DEFAULT_TIMEOUT,
|
|
180
|
+
)
|
|
181
|
+
parser.add_argument(
|
|
182
|
+
"--list-libraries",
|
|
183
|
+
action="store_true",
|
|
184
|
+
help="print the libraries this token can read, then stop",
|
|
185
|
+
)
|
|
186
|
+
parser.add_argument(
|
|
187
|
+
"--quiet", action="store_true", help="do not print the column report"
|
|
188
|
+
)
|
|
189
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
190
|
+
return parser
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def main(argv=None):
|
|
194
|
+
parser = build_parser()
|
|
195
|
+
args = parser.parse_args(argv)
|
|
196
|
+
|
|
197
|
+
if not args.url:
|
|
198
|
+
parser.error("no server address; pass --url or set %s" % URL_ENV)
|
|
199
|
+
token = os.environ.get(TOKEN_ENV)
|
|
200
|
+
if not token:
|
|
201
|
+
parser.error(
|
|
202
|
+
"no API token; set %s to a token from your account settings" % TOKEN_ENV
|
|
203
|
+
)
|
|
204
|
+
try:
|
|
205
|
+
delimiter = parse_delimiter(args.delimiter)
|
|
206
|
+
except ValueError as error:
|
|
207
|
+
parser.error(str(error))
|
|
208
|
+
if args.page_size < 1:
|
|
209
|
+
parser.error("--page-size must be at least 1")
|
|
210
|
+
|
|
211
|
+
client = Client(args.url, token, timeout=args.timeout)
|
|
212
|
+
try:
|
|
213
|
+
libraries = client.libraries()
|
|
214
|
+
if args.list_libraries:
|
|
215
|
+
for library in libraries:
|
|
216
|
+
sys.stdout.write(
|
|
217
|
+
"%s\t%s\t%s\n"
|
|
218
|
+
% (
|
|
219
|
+
library.get("name", ""),
|
|
220
|
+
library.get("mediaType", ""),
|
|
221
|
+
library.get("id", ""),
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
return 0
|
|
225
|
+
chosen = select_libraries(libraries, args.library)
|
|
226
|
+
if not chosen:
|
|
227
|
+
sys.stderr.write("no book library on this server\n")
|
|
228
|
+
return 1
|
|
229
|
+
rows = build_rows(
|
|
230
|
+
client,
|
|
231
|
+
chosen,
|
|
232
|
+
page_size=args.page_size,
|
|
233
|
+
duration_style=args.duration,
|
|
234
|
+
exact=args.exact,
|
|
235
|
+
)
|
|
236
|
+
except ApiError as error:
|
|
237
|
+
sys.stderr.write("%s\n" % error)
|
|
238
|
+
return 1
|
|
239
|
+
|
|
240
|
+
if args.out:
|
|
241
|
+
try:
|
|
242
|
+
with open(args.out, "w", newline="", encoding="utf-8") as handle:
|
|
243
|
+
write_csv(rows, handle, delimiter)
|
|
244
|
+
except OSError as error:
|
|
245
|
+
sys.stderr.write("could not write %s: %s\n" % (args.out, error))
|
|
246
|
+
return 1
|
|
247
|
+
else:
|
|
248
|
+
write_csv(rows, sys.stdout, delimiter)
|
|
249
|
+
|
|
250
|
+
if not args.quiet:
|
|
251
|
+
for line in coverage_report(rows):
|
|
252
|
+
sys.stderr.write("%s\n" % line)
|
|
253
|
+
return 0
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
if __name__ == "__main__":
|
|
257
|
+
raise SystemExit(main())
|
shelfinventory/rows.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Turn Audiobookshelf library items into CSV rows.
|
|
2
|
+
|
|
3
|
+
The column list is not ours. It is the list written by the person who asked for
|
|
4
|
+
this export on the upstream feature request, in her own order. The only change
|
|
5
|
+
is that her single "published date or year" entry becomes two columns, because
|
|
6
|
+
the server stores those as two separate fields and joining them would lose one.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime
|
|
12
|
+
|
|
13
|
+
MULTI_JOIN = "; "
|
|
14
|
+
|
|
15
|
+
COLUMNS = (
|
|
16
|
+
"title",
|
|
17
|
+
"subtitle",
|
|
18
|
+
"authors",
|
|
19
|
+
"narrators",
|
|
20
|
+
"series_name",
|
|
21
|
+
"series_sequence",
|
|
22
|
+
"genres",
|
|
23
|
+
"tags",
|
|
24
|
+
"published_date",
|
|
25
|
+
"published_year",
|
|
26
|
+
"publisher",
|
|
27
|
+
"asin",
|
|
28
|
+
"isbn",
|
|
29
|
+
"duration",
|
|
30
|
+
"date_added",
|
|
31
|
+
"library_name",
|
|
32
|
+
"relative_path",
|
|
33
|
+
"explicit",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_duration(seconds, style="hms"):
|
|
38
|
+
"""Seconds as ``H:MM:SS``, or as a plain number of seconds."""
|
|
39
|
+
if seconds is None or isinstance(seconds, bool):
|
|
40
|
+
return ""
|
|
41
|
+
try:
|
|
42
|
+
value = float(seconds)
|
|
43
|
+
except (TypeError, ValueError):
|
|
44
|
+
return ""
|
|
45
|
+
if value != value or value in (float("inf"), float("-inf")):
|
|
46
|
+
return ""
|
|
47
|
+
if style == "seconds":
|
|
48
|
+
return "%g" % value
|
|
49
|
+
if value < 0:
|
|
50
|
+
return ""
|
|
51
|
+
whole = int(round(value))
|
|
52
|
+
hours, rest = divmod(whole, 3600)
|
|
53
|
+
minutes, secs = divmod(rest, 60)
|
|
54
|
+
return "%d:%02d:%02d" % (hours, minutes, secs)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def format_added(added_at):
|
|
58
|
+
"""Epoch milliseconds as an ISO 8601 instant in UTC."""
|
|
59
|
+
if added_at is None or isinstance(added_at, bool):
|
|
60
|
+
return ""
|
|
61
|
+
try:
|
|
62
|
+
millis = float(added_at)
|
|
63
|
+
except (TypeError, ValueError):
|
|
64
|
+
return ""
|
|
65
|
+
try:
|
|
66
|
+
moment = datetime.datetime.fromtimestamp(
|
|
67
|
+
millis / 1000.0, datetime.timezone.utc
|
|
68
|
+
)
|
|
69
|
+
except (OverflowError, OSError, ValueError):
|
|
70
|
+
return ""
|
|
71
|
+
return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def format_flag(value):
|
|
75
|
+
if value is None:
|
|
76
|
+
return ""
|
|
77
|
+
return "true" if value else "false"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _text(value):
|
|
81
|
+
if value is None or isinstance(value, bool):
|
|
82
|
+
return ""
|
|
83
|
+
return str(value)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _join(values):
|
|
87
|
+
if not isinstance(values, (list, tuple)):
|
|
88
|
+
return _text(values)
|
|
89
|
+
return MULTI_JOIN.join(_text(item) for item in values if _text(item))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def split_series_name(series_name):
|
|
93
|
+
"""Split the server's joined series string into names and sequences.
|
|
94
|
+
|
|
95
|
+
The paginated library endpoint does not expose series sequences as their own
|
|
96
|
+
field. It only exposes ``seriesName``, which the server builds by joining
|
|
97
|
+
``"<name> #<sequence>"`` entries with ``", "``. Splitting that back apart is
|
|
98
|
+
a guess, not a reading: a series whose own name contains a comma, or a
|
|
99
|
+
sequence that contains " #", cannot be recovered this way. Use the exact
|
|
100
|
+
mode when that matters.
|
|
101
|
+
"""
|
|
102
|
+
text = _text(series_name).strip()
|
|
103
|
+
if not text:
|
|
104
|
+
return "", ""
|
|
105
|
+
names = []
|
|
106
|
+
sequences = []
|
|
107
|
+
for entry in text.split(", "):
|
|
108
|
+
entry = entry.strip()
|
|
109
|
+
if not entry:
|
|
110
|
+
continue
|
|
111
|
+
name, marker, sequence = entry.rpartition(" #")
|
|
112
|
+
if marker:
|
|
113
|
+
names.append(name.strip())
|
|
114
|
+
sequences.append(sequence.strip())
|
|
115
|
+
else:
|
|
116
|
+
names.append(entry)
|
|
117
|
+
sequences.append("")
|
|
118
|
+
if not any(sequences):
|
|
119
|
+
return MULTI_JOIN.join(names), ""
|
|
120
|
+
return MULTI_JOIN.join(names), MULTI_JOIN.join(sequences)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def row_from_item(item, library_name="", duration_style="hms"):
|
|
124
|
+
"""One row, built from the minified library item the list endpoint returns."""
|
|
125
|
+
item = item if isinstance(item, dict) else {}
|
|
126
|
+
media = item.get("media") if isinstance(item.get("media"), dict) else {}
|
|
127
|
+
metadata = media.get("metadata") if isinstance(media.get("metadata"), dict) else {}
|
|
128
|
+
series_name, series_sequence = split_series_name(metadata.get("seriesName"))
|
|
129
|
+
return {
|
|
130
|
+
"title": _text(metadata.get("title")),
|
|
131
|
+
"subtitle": _text(metadata.get("subtitle")),
|
|
132
|
+
"authors": _text(metadata.get("authorName")),
|
|
133
|
+
"narrators": _text(metadata.get("narratorName")),
|
|
134
|
+
"series_name": series_name,
|
|
135
|
+
"series_sequence": series_sequence,
|
|
136
|
+
"genres": _join(metadata.get("genres")),
|
|
137
|
+
"tags": _join(media.get("tags")),
|
|
138
|
+
"published_date": _text(metadata.get("publishedDate")),
|
|
139
|
+
"published_year": _text(metadata.get("publishedYear")),
|
|
140
|
+
"publisher": _text(metadata.get("publisher")),
|
|
141
|
+
"asin": _text(metadata.get("asin")),
|
|
142
|
+
"isbn": _text(metadata.get("isbn")),
|
|
143
|
+
"duration": format_duration(media.get("duration"), duration_style),
|
|
144
|
+
"date_added": format_added(item.get("addedAt")),
|
|
145
|
+
"library_name": _text(library_name),
|
|
146
|
+
"relative_path": _text(item.get("relPath") or item.get("path")),
|
|
147
|
+
"explicit": format_flag(metadata.get("explicit")),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def apply_exact_fields(row, expanded_item):
|
|
152
|
+
"""Overwrite the parsed fields with the exact ones from an expanded item.
|
|
153
|
+
|
|
154
|
+
The expanded item endpoint returns ``authors``, ``narrators`` and ``series``
|
|
155
|
+
as real lists, so nothing has to be split back apart. Fields the expanded
|
|
156
|
+
payload does not carry are left as they were.
|
|
157
|
+
"""
|
|
158
|
+
media = {}
|
|
159
|
+
if isinstance(expanded_item, dict):
|
|
160
|
+
candidate = expanded_item.get("media")
|
|
161
|
+
if isinstance(candidate, dict):
|
|
162
|
+
media = candidate
|
|
163
|
+
metadata = media.get("metadata") if isinstance(media.get("metadata"), dict) else {}
|
|
164
|
+
|
|
165
|
+
authors = metadata.get("authors")
|
|
166
|
+
if isinstance(authors, list):
|
|
167
|
+
row["authors"] = MULTI_JOIN.join(
|
|
168
|
+
_text(author.get("name") if isinstance(author, dict) else author)
|
|
169
|
+
for author in authors
|
|
170
|
+
if _text(author.get("name") if isinstance(author, dict) else author)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
narrators = metadata.get("narrators")
|
|
174
|
+
if isinstance(narrators, list):
|
|
175
|
+
row["narrators"] = _join(narrators)
|
|
176
|
+
|
|
177
|
+
series = metadata.get("series")
|
|
178
|
+
if isinstance(series, list):
|
|
179
|
+
names = []
|
|
180
|
+
sequences = []
|
|
181
|
+
for entry in series:
|
|
182
|
+
if isinstance(entry, dict):
|
|
183
|
+
names.append(_text(entry.get("name")))
|
|
184
|
+
sequences.append(_text(entry.get("sequence")))
|
|
185
|
+
else:
|
|
186
|
+
names.append(_text(entry))
|
|
187
|
+
sequences.append("")
|
|
188
|
+
names = [name for name in names if name]
|
|
189
|
+
row["series_name"] = MULTI_JOIN.join(names)
|
|
190
|
+
row["series_sequence"] = (
|
|
191
|
+
MULTI_JOIN.join(sequences) if any(sequences) else ""
|
|
192
|
+
)
|
|
193
|
+
return row
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def count_filled(rows):
|
|
197
|
+
"""How many rows have a non-empty value in each column."""
|
|
198
|
+
counts = dict((name, 0) for name in COLUMNS)
|
|
199
|
+
for row in rows:
|
|
200
|
+
for name in COLUMNS:
|
|
201
|
+
if row.get(name):
|
|
202
|
+
counts[name] += 1
|
|
203
|
+
return counts
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shelfinventory
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Export an Audiobookshelf library to CSV, one row per book, with the columns people asked for. Read only, no dependencies.
|
|
5
|
+
Author: Younes Z.
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Rezarys/shelfinventory
|
|
8
|
+
Project-URL: Issues, https://github.com/Rezarys/shelfinventory/issues
|
|
9
|
+
Keywords: audiobookshelf,audiobook,library,inventory,csv,export,catalog,selfhosted
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Utilities
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# shelfinventory
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
pip install shelfinventory
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Export an Audiobookshelf library to CSV, one row per book, with the columns people actually asked for on the upstream request thread. It reads over the HTTP API and writes nothing back to your server.
|
|
28
|
+
|
|
29
|
+
Why it exists: the request thread [advplyr/audiobookshelf#2085](https://github.com/advplyr/audiobookshelf/issues/2085) asks for a CSV inventory of what is really in the library. The workarounds described there are exporting from another tool, which reflects a different catalog, or opening the SQLite database with a generic client. This does the same job in one command, through the API, without touching the database file.
|
|
30
|
+
|
|
31
|
+
## Use
|
|
32
|
+
|
|
33
|
+
Set a token, then run it.
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
export AUDIOBOOKSHELF_TOKEN=your-api-token
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
shelfinventory --url https://abs.example
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
That prints the CSV on standard output. To write a file instead:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
shelfinventory --url https://abs.example --out books.csv
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The token is the API token from your Audiobookshelf account settings. It is read from the environment only, never from an argument, so it does not land in your shell history. The server address can also come from `AUDIOBOOKSHELF_URL`.
|
|
50
|
+
|
|
51
|
+
Titles contain commas, so the delimiter is yours to choose:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
shelfinventory --url https://abs.example --delimiter tab
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`--delimiter` takes one character, or one of `comma`, `semicolon`, `tab`, `pipe`, `colon`. Fields are quoted properly whatever you pick, so a comma inside a title never splits a row.
|
|
58
|
+
|
|
59
|
+
## Columns
|
|
60
|
+
|
|
61
|
+
Eighteen columns, in this order:
|
|
62
|
+
|
|
63
|
+
- `title`
|
|
64
|
+
- `subtitle`
|
|
65
|
+
- `authors`
|
|
66
|
+
- `narrators`
|
|
67
|
+
- `series_name`
|
|
68
|
+
- `series_sequence`
|
|
69
|
+
- `genres`
|
|
70
|
+
- `tags`
|
|
71
|
+
- `published_date`
|
|
72
|
+
- `published_year`
|
|
73
|
+
- `publisher`
|
|
74
|
+
- `asin`
|
|
75
|
+
- `isbn`
|
|
76
|
+
- `duration`
|
|
77
|
+
- `date_added`
|
|
78
|
+
- `library_name`
|
|
79
|
+
- `relative_path`
|
|
80
|
+
- `explicit`
|
|
81
|
+
|
|
82
|
+
That list is not mine. It is the list written on the request thread, in the same order, with one change: the single "published date or year" entry became two columns, because the server stores those as two separate fields and merging them would throw one away.
|
|
83
|
+
|
|
84
|
+
Multiple genres, tags or series are joined with `; ` inside one cell. Multiple authors and narrators get that same `; ` join only when you pass `--exact`; without it, they keep whatever separator the server itself used to join them. `duration` is `H:MM:SS` by default, or a number of seconds with `--duration seconds`. `date_added` is an ISO 8601 instant in UTC.
|
|
85
|
+
|
|
86
|
+
Every run, unless you pass `--quiet`, prints a short report on standard error saying how many books were exported and which columns your server left empty for every one of them. That report is the honest answer to whether your library really carries this metadata, and it never mixes into the CSV.
|
|
87
|
+
|
|
88
|
+
With no `--library`, every book library goes into the same CSV, and the `library_name` column says which one each row came from.
|
|
89
|
+
|
|
90
|
+
## Series sequences, and one honest caveat
|
|
91
|
+
|
|
92
|
+
The paginated library endpoint does not expose a series sequence as its own field. It exposes one joined string per book, built by the server as `"<name> #<sequence>"` entries separated by a comma. This tool splits that back apart, which is a guess and not a reading: a series whose own name contains a comma cannot be recovered that way.
|
|
93
|
+
|
|
94
|
+
If exact series and author fields matter to you, use:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
shelfinventory --url https://abs.example --exact
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
That reads each book a second time, through the endpoint that returns real lists, so nothing has to be split. It costs one extra request per book, which is why it is not the default.
|
|
101
|
+
|
|
102
|
+
## Other options
|
|
103
|
+
|
|
104
|
+
- `--library NAME` picks one library by name or id, and repeats.
|
|
105
|
+
- `--list-libraries` prints what the token can read, then stops.
|
|
106
|
+
- `--page-size N` sets how many books are fetched per request.
|
|
107
|
+
- `--timeout SECONDS` sets the request timeout.
|
|
108
|
+
- `--quiet` drops the column report.
|
|
109
|
+
|
|
110
|
+
Podcast libraries are never exported. The columns above are book columns.
|
|
111
|
+
|
|
112
|
+
## What is not verified
|
|
113
|
+
|
|
114
|
+
Being plain about this matters more than looking finished:
|
|
115
|
+
|
|
116
|
+
- No real Audiobookshelf instance has been read with this tool. The response shapes it expects come from the upstream public source code, read on 2026-09-30: `server/models/Book.js`, `server/models/LibraryItem.js` and `server/controllers/LibraryController.js`.
|
|
117
|
+
- The tests run on sample payloads written for the tests, not on captured traffic from a live server.
|
|
118
|
+
- Whether your server fills all eighteen columns is not something anyone has measured. The report printed on standard error is there so you can see it for yourself on the first run.
|
|
119
|
+
|
|
120
|
+
If you run it on a real library, please say so on the issue tracker, whether it worked or not. The paragraph above gets corrected the day someone reports a first-hand result.
|
|
121
|
+
|
|
122
|
+
## Requirements
|
|
123
|
+
|
|
124
|
+
Python 3.9 or newer. The standard library only, no dependencies.
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT. Built with AI assistance, reviewed and tested by me.
|
|
129
|
+
|
|
130
|
+
Younes Z.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
shelfinventory/__init__.py,sha256=_u_Z2HSgCe1bK5_WPhjaEAUS2c-9FbyE9kZQ3opHtGM,183
|
|
2
|
+
shelfinventory/api.py,sha256=-i4v6eAPtips351AdPX5AhuewiFXOu-UWo6qxiRl0Jg,4780
|
|
3
|
+
shelfinventory/cli.py,sha256=yRFmobcs56ThAcMvvzoqmALZwFS7U5tbfRQ9QYehGqU,7831
|
|
4
|
+
shelfinventory/rows.py,sha256=-_rXtMrz2l-GD1gk7rl-pgre4LMsuMoZ-bSMVnjCWak,6750
|
|
5
|
+
shelfinventory-0.1.0.dist-info/licenses/LICENSE,sha256=Y2A2qHmmnYqakqTX3wPyhl2UBUwP8eCtG7yH1ylrWRI,1066
|
|
6
|
+
shelfinventory-0.1.0.dist-info/METADATA,sha256=mxe3qr99LYILhtTrFE_ByyfCU3bCj1GuPc6w-AS4IF4,5680
|
|
7
|
+
shelfinventory-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
shelfinventory-0.1.0.dist-info/entry_points.txt,sha256=ItLveRLUHIQ5c31QEDX-_xgDUSxAFqCBkrEZEiLcpJk,59
|
|
9
|
+
shelfinventory-0.1.0.dist-info/top_level.txt,sha256=IFNmcdpH79hPEAVev3u0b_GYSeOTUsJmVwedMQ0J-T0,15
|
|
10
|
+
shelfinventory-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Younes Z.
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
shelfinventory
|