diff --git a/CHANGELOG.md b/CHANGELOG.md index 55871e1..29f76cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,11 @@ point is tag `upstream-baseline`. still appends a `run` record carrying `exit_code` and `error`. ### Fixed +- Series parts survive a trip through the run log. The log writes `seriesparts` as `Name part` while the + log-mode reader (`fix.csv`) split on `#`, so the whole string became the series name and the part was lost + (upstream #27, half of it). The reader now pairs `seriesparts` with the `series` column; a decimal part is + logged as `17.5` instead of `17 5` (the only visible change in the corpus replay: the fuzzy-match string of four + novellas, same matches). - `Config/dedupe_roots`: a multi-file release whose discs are already hardlinked under *different* library folders (a prior `multibook` run, or a file moved in the library) is no longer reported as already filed. The first folder used to win, `hardlinkUnlessFiled` skipped, and the processed marker then hid the rest of diff --git a/booktree.py b/booktree.py index fb6fa4f..2e8240a 100644 --- a/booktree.py +++ b/booktree.py @@ -53,7 +53,7 @@ def buildTreeFromLog(files, logfile, cfg): bf.isMatched = (str(row["isMatched"]).lower() == "true") bf.ffprobeBook.setAuthors(row["id3-authors"]) bf.ffprobeBook.setNarrators(row["id3-narrators"]) - bf.ffprobeBook.setSeries(row["id3-seriesparts"]) + bf.ffprobeBook.setSeriesFromLog(row.get("id3-series", ""), row["id3-seriesparts"]) #does this book exist? hashKey=myx_utilities.getHash(f"{i}-{row['book']}") diff --git a/docs/FORK.md b/docs/FORK.md index 30dbbea..5a24534 100644 --- a/docs/FORK.md +++ b/docs/FORK.md @@ -29,7 +29,7 @@ Scripts built on upstream booktree keep working: | 3 | Missing id3 → the whole filename is the search title (`Author - Title.m4b` scores 41 against its own MAM entry). Fixed: release-name parsing (`flags/parse_names`, `--legacy-names` for the old behaviour), see CONFIG.md | #26 | | 4 | Empty Audible / MAM results and skeleton per-ASIN responses (`{asin, asset_details, is_vvab}`) are cached forever. Fixed: TTLs by kind and emptiness, errors never cached, `--refresh`, `--json-log`; MAM requests spaced ≥ 6 s and capped per run (see CONFIG.md) | #25 | | 5 | MAM session duplicated in every config file and kept in a pickle; `title_patterns` contain `"\b"` JSON escapes that become backspaces; exit code is 0 on failure. Fixed: `MAM_SESSION` / `MAM_SESSION_FILE`, JSON cookie store, backspace patterns repaired, exit codes 0/1/2 (see CONFIG.md) | — | -| 6 | `Series # - Title` folders: the log writes `seriesparts` as `Name part`, the log reader splits on `#` | #27 | +| 6 | `Series # - Title` folders: the log writes `seriesparts` as `Name part`, the log reader splits on `#`. Fixed: the reader pairs `seriesparts` with `series`, decimal parts are logged as `17.5`, and a series entry without a part is filed with `target_path/in_series_no_part` (default `Series - Title`) | #27 | | 6b | Multi-disc releases (`cd1/`, `Disc 01/`) are grouped per disc folder, so each disc is matched on its own, the runtime evidence is one disc long (the wrong edition can win), and `book` logs as `cd1..cdN`. Two releases that both use `cd1/` were also merged into one book. Fixed: grouping walks past disc parents to the release folder | #26 | | 8 | Everything around a run lived in per-host wrapper scripts (ntfy summary, Audiobookshelf scan, inode de-dupe, an ASIN fixer that rewrote `fix.csv`). Added as config, off by default: `notify`, `abs`, `dedupe_roots`, `--pin RELEASE=ASIN` with `--remember` to keep the correction in the hints file (see "Correcting a match" in CONFIG.md) | — | | 7 | mousehole cookie integration. Fixed: `mousehole_state_file` / `MOUSEHOLE_STATE_FILE`, reads mousehole's v2 (`cookie`) and legacy (`currentCookie`) state files (PR #24 read only the legacy key) | #24 | diff --git a/myx_classes.py b/myx_classes.py index 08e4726..337fc61 100644 --- a/myx_classes.py +++ b/myx_classes.py @@ -114,12 +114,14 @@ def getNarrators(self, delimiter=",", encloser="", stripaccents=True): return "" def getSeriesParts(self, delimiter=",", encloser="", stripaccents=True): + #the name is cleansed like a contributor (as upstream did); the part is kept as is, so that a decimal + #part such as 17.5 is logged as 17.5 and not as "17 5" seriesparts = [] for s in self.series: if len(s.name.strip()): - seriesparts.append(Contributor(f"{s.name} {s.separator}{s.part}")) - - return myx_utilities.getList(seriesparts, delimiter, encloser, stripaccents=True) + text = " ".join(f"{myx_utilities.cleanseAuthor(s.name)} {s.separator}{s.part}".split()) + seriesparts.append(f"{encloser}{text}{encloser}") + return delimiter.join(seriesparts) def setAuthors(self, authors): #Given a csv of authors, convert it to a list @@ -145,6 +147,31 @@ def setSeries(self, series): else: self.series.append(Series(str(p[0]).strip(), "")) + def setSeriesFromLog(self, series, seriesparts): + """Rebuild the series from a run-log row. The log writes the names in `series` and "Name part" in + `seriesparts` without a `#`, so the part can only be told from a name that itself ends in a number + ("Area 51") with the names at hand. Without names (a hand-written row) fall back to `Name #part`.""" + names = [n.strip() for n in str(series or "").split(",") if n.strip()] + parts = [p.strip() for p in str(seriesparts or "").split(",") if p.strip()] + if not names: + return self.setSeries(str(seriesparts or "")) + #`series` went through cleanseSeries, `seriesparts` through cleanseAuthor: compare both the same way + norm = lambda text: myx_utilities.cleanseAuthor(myx_utilities.cleanseSeries(text)) # noqa: E731 + for name in names: + key = norm(name) + part = "" + for p in parts: + q = norm(p) + if q == key: + break + if q.startswith(key + " ") or q.startswith(key + "#"): + part = q[len(key):].strip().lstrip("#").strip() + m = re.fullmatch(r"(\d+) (\d+)", part) # "17 5": a decimal part logged before the fix + if m: + part = f"{m.group(1)}.{m.group(2)}" + break + self.series.append(Series(name, part)) + def getDictionary(self, book, ns=""): book[f"{ns}matchRate"]=self.matchRate book[f"{ns}asin"]=self.asin diff --git a/tests/test_logmode.py b/tests/test_logmode.py index 2c41ecb..3923d8b 100644 --- a/tests/test_logmode.py +++ b/tests/test_logmode.py @@ -150,3 +150,43 @@ def run_once(**over): self.assertIn("Applying hint for junk: {'refresh': True}", third) self.assertIn("Pinned ASIN B0CC3NZ34S accepted", third) self.assertEqual(len(booktree.httpx.calls) if hasattr(booktree.httpx, "calls") else 2, 2) + + +class SeriesRoundTripTest(unittest.TestCase): + """upstream #27: the log writes seriesparts as "Name part" (no '#'), the reader split on '#' and lost the part.""" + + def test_series_part_survives_a_trip_through_the_log(self): + import myx_classes + src = myx_classes.Book(title="The Honeymoon Heist") + src.series = [myx_classes.Series("Pike Logan", "17.5"), myx_classes.Series("Area 51", ""), myx_classes.Series("Dune", "1")] + row = {} + src.getDictionary(row, "id3-") + self.assertEqual(row["id3-series"], "Pike Logan,Area 51,Dune") + self.assertEqual(row["id3-seriesparts"], "Pike Logan 17.5,Area 51,Dune 1") # CSV value unchanged + back = myx_classes.Book() + back.setSeriesFromLog(row["id3-series"], row["id3-seriesparts"]) + self.assertEqual([(s.name, s.part) for s in back.series], [("Pike Logan", "17.5"), ("Area 51", ""), ("Dune", "1")]) + self.assertEqual(back.getSeriesParts(), src.getSeriesParts()) + # a row written before the fix ("17 5"), and names the two columns cleanse differently + back = myx_classes.Book() + back.setSeriesFromLog("Pike Logan,Hitchhiker's Guide: Trilogy", "Pike Logan 17 5,Hitchhikers Guide: Trilogy 2") + self.assertEqual([(s.name, s.part) for s in back.series], [("Pike Logan", "17.5"), ("Hitchhiker's Guide: Trilogy", "2")]) + # the old reader turned the whole string into a series named "Pike Logan 17.5" + legacy = myx_classes.Book() + legacy.setSeries("Pike Logan 17.5") + self.assertEqual([(s.name, s.part) for s in legacy.series], [("Pike Logan 17.5", "")]) + + def test_hand_written_rows_still_accept_name_hash_part(self): + import myx_classes + b = myx_classes.Book() + b.setSeriesFromLog("", "Jack Reacher #3") # no series column: legacy '#' form + self.assertEqual([(s.name, s.part) for s in b.series], [("Jack Reacher", "3")]) + b = myx_classes.Book() + b.setSeriesFromLog("Jack Reacher", "Jack Reacher #3") # '#' with the names present + self.assertEqual([(s.name, s.part) for s in b.series], [("Jack Reacher", "3")]) + b = myx_classes.Book() + b.setSeriesFromLog("Jack Reacher", "") # names only + self.assertEqual([(s.name, s.part) for s in b.series], [("Jack Reacher", "")]) + b = myx_classes.Book() + b.setSeriesFromLog("", "") + self.assertEqual(b.series, [])