<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Python on fullast.nu</title><link>https://blog.fullast.nu/tags/python/</link><description>Recent content in Python on fullast.nu</description><generator>Hugo</generator><language>en</language><copyright>Text and images CC BY-SA 4.0, code snippets MIT - credit blog.fullast.nu</copyright><atom:link href="https://blog.fullast.nu/tags/python/index.xml" rel="self" type="application/rss+xml"/><item><title>Cleaning up metadata in a large Audiobookshelf library, one night at a time</title><link>https://blog.fullast.nu/posts/2026-10-28-audiobookshelf-metadata-cleanup/</link><pubDate>Thu, 29 Oct 2026 15:00:00 +0100</pubDate><guid>https://blog.fullast.nu/posts/2026-10-28-audiobookshelf-metadata-cleanup/</guid><description>How I taught a nightly job to identify tens of thousands of audiobooks and e-books from a national library catalogue and two store APIs - with per-night rollback, an honest account of my own mistakes, and the regex that turned Swedish into English.</description><category>audiobookshelf</category><category>metadata</category><category>python</category><category>self-hosting</category><category>audiobooks</category><category>ebooks</category><category>automation</category><category domain="lemmy">selfhosted@lemmy.world</category><content:encoded>&lt;p&gt;&lt;a href="https://www.audiobookshelf.org/"&gt;Audiobookshelf&lt;/a&gt; is a great self-hosted audiobook and e-book server, as long as the&#10;files it indexes come with decent metadata. Mine mostly didn&amp;rsquo;t. Years of collecting had left a large library (tens of&#10;thousands of items) where a good share of entries looked like this:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;Title: Anna.Author-The.Winter.House.SWEDISH.AUDIOBOOK.WEB-GRP&#10;Author: (empty)&#10;Genres: Speech&#10;Description: Ripped by someone, enjoy.&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That is the &lt;em&gt;folder name&lt;/em&gt; as title, no author, a genre that is really an ID3 content type, and a description that is&#10;release-group graffiti. Multiply by thousands and the library is unbrowsable: no author pages, no series, search that&#10;only works if you remember the folder name.&lt;/p&gt;&#10;&lt;p&gt;This post is about the nightly job that fixed it, the rules that kept it from making things worse, and the four&#10;bugs it took to get right. Everything below is Python standard library plus public APIs; nothing exotic.&lt;/p&gt;&#10;&lt;p&gt;&lt;img alt="Pipeline: folder name or existing metadata -&amp;gt; candidate queries -&amp;gt; Libris / Storytel / Audible / Google Books -&amp;gt; clear-match rule -&amp;gt; plan file -&amp;gt; apply -&amp;gt; per-night rollback" loading="lazy" src="https://blog.fullast.nu/images/abs-pipeline.svg"&gt;&lt;/p&gt;&#10;&lt;h2 id="rule-one-fill-only-what-is-empty"&gt;Rule one: fill only what is empty&lt;/h2&gt;&#10;&lt;p&gt;The single most important design decision: &lt;strong&gt;never overwrite a field that has a value&lt;/strong&gt;. Title and author are the&#10;exception for items that have no author at all (the release-name cases above, where the &amp;ldquo;title&amp;rdquo; is garbage by&#10;definition). Everything else (subtitle, ISBN, language, series and sequence, genres, narrators, publisher, year,&#10;description) is filled only when empty, or when it is &lt;em&gt;certainly junk&lt;/em&gt;:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;descriptions matching known release-group signatures or shorter than a few words,&lt;/li&gt;&#10;&lt;li&gt;genres that are ID3 leftovers (&lt;code&gt;Speech&lt;/code&gt;, &lt;code&gt;Other&lt;/code&gt;, &lt;code&gt;F&lt;/code&gt;, &lt;code&gt;(12)&lt;/code&gt;),&lt;/li&gt;&#10;&lt;li&gt;the same &amp;ldquo;description&amp;rdquo; appearing on books by three or more different authors (a copy-pasted blurb).&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Junk is cleared even when there is no replacement. Weak values (a one-word genre like &lt;code&gt;Fiction&lt;/code&gt;) are replaced only&#10;when a matched record has something better.&lt;/p&gt;&#10;&lt;h2 id="where-the-metadata-comes-from"&gt;Where the metadata comes from&lt;/h2&gt;&#10;&lt;p&gt;Three sources, in this order:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;The national library catalogue.&lt;/strong&gt; In Sweden that is &lt;a href="https://libris.kb.se/"&gt;Libris&lt;/a&gt;, whose &lt;code&gt;xsearch&lt;/code&gt; API is&#10;open, needs no key, and returns JSON or MARCXML. It knows every published edition with ISBN, publisher, year and,&#10;crucially for later, the &lt;em&gt;original title&lt;/em&gt; of a translation (MARC field 240).&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A store API&lt;/strong&gt; with rich blurbs, series information, narrators and covers. Storytel&amp;rsquo;s search endpoint turned out&#10;to be usable as-is for Swedish titles.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Audible&lt;/strong&gt; through Audiobookshelf&amp;rsquo;s own provider endpoint (&lt;code&gt;/api/search/books?provider=audible&lt;/code&gt;) for English&#10;titles, and Google Books as a last resort: for descriptions and ISBNs, and for identifying a book nothing else&#10;found (more on its stricter rule below).&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;A query is just &lt;code&gt;author + title&lt;/code&gt;. For items with a release name, the name is parsed into &lt;em&gt;candidates&lt;/em&gt;: the&#10;&lt;code&gt;Firstname.Lastname.Words.Of.Title&lt;/code&gt; form has no separator, so every split into 1-4 author words is tried, and the&#10;catalogue decides which one is right.&lt;/p&gt;&#10;&lt;h2 id="rule-two-a-match-must-be-clear"&gt;Rule two: a match must be clear&lt;/h2&gt;&#10;&lt;p&gt;A record is accepted only when &lt;strong&gt;the author matches&lt;/strong&gt; (same normalised name, or at least the same surname) &lt;strong&gt;and the&#10;title is close&lt;/strong&gt; (normalised equality, prefix, or a similarity ratio above 0.85). Anything less is logged as a miss&#10;and left alone. Misses are retried after 30 days, because stores add titles all the time.&lt;/p&gt;&#10;&lt;p&gt;That rule rejected a lot of plausible-looking matches, which is exactly the point: an audiobook library with a&#10;thousand confident wrong matches is worse than one with a thousand gaps.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;need&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;norm_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;norm_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;False&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39; &amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39; &amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; \&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;difflib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SequenceMatcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;need&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The prefix clause is there for subtitles: &amp;ldquo;The Winter House&amp;rdquo; should match &amp;ldquo;The Winter House: A Novel&amp;rdquo;. It is also the&#10;loosest part of the rule. When Google Books became the last identification attempt, it happily matched one book in&#10;a children&amp;rsquo;s series, say &lt;em&gt;Harbour Town: The Dragon Summer&lt;/em&gt;, to another, &lt;em&gt;Harbour Town: The End&lt;/em&gt;, because both titles&#10;start with the series name. The other sources rarely have that problem; Google&amp;rsquo;s broad catalogue does. So a Google match must pass a stricter test: whole-title&#10;similarity of 0.85 or more, no prefix shortcut, and the same language as the release&amp;rsquo;s own tag when Google states one.&#10;The looser the source, the stricter the rule.&lt;/p&gt;&#10;&lt;p&gt;Google Books has two more quirks worth knowing about. The free quota is 1,000 requests per day, counted per &lt;em&gt;Pacific&lt;/em&gt;&#10;day, so a per-run limit is not enough: my daytime tests plus the night job went over it, and the rest of the night got&#10;nothing but &amp;ldquo;quota exceeded&amp;rdquo;. The counter now lives in a small file shared by every run. And roughly half of all&#10;requests get a &lt;code&gt;503 Service temporarily unavailable&lt;/code&gt; at random, whatever the pacing, the headers or the query; the&#10;same request a few seconds later usually works. So each lookup gets two retries, and a book that only ever got 503s is&#10;not counted as &amp;ldquo;tried&amp;rdquo;. It comes back the next day instead of in 30. It now runs as its own short daily job right&#10;after the quota resets, and identifies about one in ten of the books nothing else could. (The first day also&#10;showed why the language check matters: Google happily returns the Norwegian original of a Swedish translation, and my&#10;table of Google&amp;rsquo;s language codes only knew Swedish and English, so &amp;ldquo;Norwegian&amp;rdquo; looked like &amp;ldquo;no language stated&amp;rdquo;. A handful of books got&#10;Norwegian or Danish titles before I noticed; the plan file put them back.)&lt;/p&gt;&#10;&lt;h2 id="the-night-job-and-its-parachute"&gt;The night job and its parachute&lt;/h2&gt;&#10;&lt;p&gt;The job runs from late evening to early morning, before the server&amp;rsquo;s own scheduled scan. Four things make it safe&#10;to let loose on a library this size:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;A fresh native backup first.&lt;/strong&gt; It triggers Audiobookshelf&amp;rsquo;s own backup through the API, verifies that a new&#10;backup of plausible size exists, and refuses to start otherwise.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A plan file per night.&lt;/strong&gt; Every change is appended, &lt;em&gt;with the previous values&lt;/em&gt;, to that night&amp;rsquo;s JSON file before&#10;it is applied. &lt;code&gt;--rollback &amp;lt;file&amp;gt;&lt;/code&gt; puts everything back, item by item. I have used it. More than once.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A deadline fixed at start.&lt;/strong&gt; The first version computed the deadline &lt;em&gt;after&lt;/em&gt; the multi-minute backup; one night&#10;that rolled the deadline into the next day and the job ran into the morning scan.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A weekly spot-check.&lt;/strong&gt; Rollback only helps if someone notices. Once a week a separate script reads every change&#10;from the last seven nights and flags the patterns that have bitten me before: an author the folder name does not&#10;mention at all, a publisher in the author field, two people glued into one &amp;ldquo;name&amp;rdquo;, a series name used as the&#10;author, a language that contradicts the release&amp;rsquo;s own tag. Each flag is re-checked against the item &lt;em&gt;as it is now&lt;/em&gt;,&#10;so problems that were already repaired don&amp;rsquo;t count. The result is one push notification with counts and a few&#10;examples. The first run flagged a dozen or so out of well over ten thousand changes.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Throughput was a few thousand items a night, which meant the backlog took about a week. Since then it only touches new&#10;items and retries.&lt;/p&gt;&#10;&lt;h2 id="bug-one-the-language-tag-that-read-en-as-english"&gt;Bug one: the language tag that read &amp;ldquo;En&amp;rdquo; as English&lt;/h2&gt;&#10;&lt;p&gt;Release names carry a language tag somewhere after the title: &lt;code&gt;...-SE-2020-GRP&lt;/code&gt;, &lt;code&gt;...SWEDISH.AUDIOBOOK...&lt;/code&gt;,&#10;&lt;code&gt;...-EN-...&lt;/code&gt;. The first regex took the &lt;em&gt;first&lt;/em&gt; match. Swedish has a very common word spelled &lt;strong&gt;&amp;ldquo;En&amp;rdquo;&lt;/strong&gt; (&amp;ldquo;a&amp;rdquo;, &amp;ldquo;one&amp;rdquo;).&#10;So &lt;code&gt;Firstname_Lastname_-_En_Gang_I_Byn-...-SE-2020&lt;/code&gt; was tagged English, got an English ISBN, lost its Swedish one,&#10;and was looked up on Audible for good measure.&lt;/p&gt;&#10;&lt;p&gt;The fix is one word: take the &lt;strong&gt;last&lt;/strong&gt; tag, because tags follow the title.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;(?i)(?&amp;lt;=[._-])(swedish|english|danish|norwegian|se|en)(?=[._-])&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;se&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;Swedish&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;en&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;English&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;capitalize&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="bug-two-the-original-title-finds-the-original-edition"&gt;Bug two: the original title finds the original edition&lt;/h2&gt;&#10;&lt;p&gt;Some Swedish releases are named after the book&amp;rsquo;s &lt;em&gt;original&lt;/em&gt; title: &lt;code&gt;Author.Name.The.Winter.House.SWEDISH...&lt;/code&gt; for a&#10;translation sold as &lt;em&gt;Vinterhuset&lt;/em&gt;. Searching the stores by that title finds the &lt;strong&gt;English&lt;/strong&gt; edition: English ISBN,&#10;English blurb, English narrator, language &amp;ldquo;English&amp;rdquo;. The library said Swedish, the metadata said otherwise, and a&#10;good four per cent of one night&amp;rsquo;s matches were like that.&lt;/p&gt;&#10;&lt;p&gt;Two changes:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;The release&amp;rsquo;s own language tag wins. A matched edition in another language contributes only title, author and&#10;series; its ISBN, publisher, description and narrators are dropped. A record with no language but an English-range&#10;ISBN (978-0 / 978-1) counts as foreign too.&lt;/li&gt;&#10;&lt;li&gt;Then &lt;strong&gt;find the Swedish edition&lt;/strong&gt;: a Libris MARCXML query restricted to Swedish records (&lt;code&gt;spr:swe&lt;/code&gt;) where field 240,&#10;the original title, matches the title we have. That returns &lt;em&gt;Vinterhuset&lt;/em&gt; with its Swedish ISBN and publisher; the&#10;store search with the Swedish title then finds the Swedish blurb and narrator.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Danish and Finnish ISBNs on Swedish audiobooks, by the way, are normal: several big audiobook publishers are&#10;registered there. The first version of my check flagged hundreds of those as errors. Check your assumptions against&#10;the data before you &amp;ldquo;fix&amp;rdquo; it.&lt;/p&gt;&#10;&lt;h2 id="bug-three-the-author-that-was-never-right"&gt;Bug three: the author that was never right&lt;/h2&gt;&#10;&lt;p&gt;Fill-only-when-empty has a blind spot: metadata that is &lt;em&gt;wrong&lt;/em&gt; rather than missing. A scan found a surprising number&#10;of items whose stored author shared &lt;strong&gt;no name word&lt;/strong&gt; with the author in the folder name. Some were the right book with&#10;a translator or narrator credited as author. Others were a different book entirely: a Swedish crime novel tagged as a&#10;German dictionary, a children&amp;rsquo;s series carrying a biography&amp;rsquo;s description.&lt;/p&gt;&#10;&lt;p&gt;So a second pass re-identifies those from the folder name alone, with two outcomes:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Same book&lt;/strong&gt; (old title close to the found one): the found author goes first, empty fields are filled, nothing is&#10;cleared. What happens to the old author name turned out to be bug four.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Different book&lt;/strong&gt;: the new identity replaces the old one, and fields the new record cannot fill are cleared, because&#10;they belonged to the other book.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Plus a guard: if the volume numbers disagree (&lt;code&gt;Series 1&lt;/code&gt; matched to &lt;code&gt;Series 3&lt;/code&gt;), skip. The dry run caught that one.&lt;/p&gt;&#10;&lt;p&gt;The first batch of a few hundred was reviewed by hand before applying; it then joined the nightly job with its own&#10;state, so an item fixed by the first pass is not &amp;ldquo;done&amp;rdquo; for the second.&lt;/p&gt;&#10;&lt;h2 id="bug-four-lossless-kept-the-wrong-name"&gt;Bug four: &amp;ldquo;lossless&amp;rdquo; kept the wrong name&lt;/h2&gt;&#10;&lt;p&gt;My first version of the same-book case kept the old author next to the found one. The reasoning sounded careful:&#10;the old name might be a co-author, so don&amp;rsquo;t throw anything away. Lossless.&lt;/p&gt;&#10;&lt;p&gt;The first full night of it said otherwise. Of the few hundred books re-identified that night, roughly four in ten now listed two authors, and&#10;in most of them the second name was simply the leftover of the original wrong match: a Swedish crime novel credited&#10;to its real author &lt;em&gt;and&lt;/em&gt; an American romance writer. A few dozen had a publisher as co-author. And Audiobookshelf keeps&#10;an item&amp;rsquo;s existing author first when you add one, so the wrong name was the one you saw.&lt;/p&gt;&#10;&lt;p&gt;&amp;ldquo;Keep it, it might be right&amp;rdquo; is not lossless. It is keeping an error with a note attached. The rule now is that an old&#10;name stays only when something confirms it:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;the matched record lists that person as a creator (library records include illustrators and translators),&lt;/li&gt;&#10;&lt;li&gt;the folder name contains the name, or&lt;/li&gt;&#10;&lt;li&gt;a source finds this title under that name.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Publisher-like names never stay, and a found &amp;ldquo;author&amp;rdquo; that is really the series name (yes, that happened: a&#10;children&amp;rsquo;s series whose title character was taken for the writer) is rejected. A one-off repair script re-planned&#10;every affected item from its pre-night values with the fixed rule and rewrote only the author field, with its own&#10;rollback file. Picture books came through with writer and illustrator intact, because the library record names&#10;both.&lt;/p&gt;&#10;&lt;p&gt;The same week, the opposite case: folder names that list two authors, where only one was found. A name like&#10;&lt;code&gt;Anne_Author_Bea_Writer_-_Title&lt;/code&gt; cannot be split reliably, but the stores list every author of a book. So after a&#10;match, any other author from the record whose full name also appears in the folder name is added. A narrator or&#10;translator the folder doesn&amp;rsquo;t mention is never added.&lt;/p&gt;&#10;&lt;h2 id="what-it-looks-like-now"&gt;What it looks like now&lt;/h2&gt;&#10;&lt;p&gt;Author pages exist. Series are grouped and numbered. Swedish books have Swedish titles and blurbs. Searching by a&#10;title you half remember works. The job still runs every night, mostly to catch new arrivals, and reports a one-line&#10;summary with the rollback command for that night.&lt;/p&gt;&#10;&lt;p&gt;If you take one thing from this: &lt;strong&gt;plan, then apply, and keep the previous values next to every change.&lt;/strong&gt; The rest is&#10;regex and patience.&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;Tools: Audiobookshelf, Python 3 (standard library only), the Libris xsearch API, Storytel&amp;rsquo;s search endpoint, Audible&#10;via Audiobookshelf, Google Books. Scripts are plain cron jobs on a small Linux box. They are tailored to this library, so I have not&#10;published them; if a step is unclear or you want more detail on one, ask in the comments.&lt;/em&gt;&lt;/p&gt;&#10;</content:encoded></item></channel></rss>