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.
@@ -0,0 +1,8 @@
1
+ """Export an Audiobookshelf library as one CSV row per book.
2
+
3
+ Read only. The package never sends anything but HTTP GET requests.
4
+ """
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = ["__version__"]
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ shelfinventory = shelfinventory.cli:main
@@ -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