Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ first release is 3.0.0 because exit codes and the cookie store changed in ways a

## Unreleased

### Fixed
- In `metadata: log` mode each CSV row is processed on its own, but the never-expiring processed marker
was keyed only on the `book` column. After grouping discs under the release folder (#15) a later run
(or a name-only marker from a prior hybrid pass) printed `Skipping: … already processed` for every
remaining disc, so `cd2` never reached the library. The marker is now per release+file.
- The same log reader still rebuilt Audible/MAM series with `setSeries(seriesparts)` (splits on `#`).
`#18` already paired `series` + `seriesparts` for the id3 columns; without that, a matched row whose
`paths` was blanked (the documented re-pin workflow) filed as `Series 17.5 - Title` and wrote that
name into `metadata.opf`.

## 3.0.4 - 2026-09-22

### Fixed
Expand Down
7 changes: 5 additions & 2 deletions booktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ def buildTreeFromLog(files, logfile, cfg):
book[hashKey].paths = (str(row["paths"]))
book[hashKey].isMatched = (str(row["isMatched"]).lower() == "true")
book[hashKey].files.append(bf)
# each log row is its own work item (CONFIG: fill pins on every row). Cache by
# release + file, not release alone: #15 writes the same `book` for every disc.
book[hashKey].cacheKey = f"{row['book']}|{fullpath}"
book[hashKey].ffprobeBook = book[hashKey].files[0].ffprobeBook
#an ASIN in the input log is an explicit instruction (fix.csv): authoritative, even
#when the id3 title/author disagree with the Audible product
Expand All @@ -77,12 +80,12 @@ def buildTreeFromLog(files, logfile, cfg):
book[hashKey].bestAudibleMatch = myx_classes.Book(asin=str(row["adb-asin"]), title=str(row["adb-title"]), subtitle=row["adb-subtitle"], publisher=row["adb-publisher"], length=row["adb-length"], duration=row["adb-duration"], language=row["adb-language"])
book[hashKey].bestAudibleMatch.setAuthors(row["adb-authors"])
book[hashKey].bestAudibleMatch.setNarrators(row["adb-narrators"])
book[hashKey].bestAudibleMatch.setSeries(row["adb-seriesparts"])
book[hashKey].bestAudibleMatch.setSeriesFromLog(row.get("adb-series", ""), row["adb-seriesparts"])
elif book[hashKey].metadata == "mam":
book[hashKey].bestMAMMatch = myx_classes.Book(asin=str(row["mam-asin"]), title=str(row["mam-title"]), subtitle=row["mam-subtitle"], publisher=row["mam-publisher"], length=row["mam-length"], duration=row["mam-duration"], language=row["mam-language"])
book[hashKey].bestMAMMatch.setAuthors(row["mam-authors"])
book[hashKey].bestMAMMatch.setNarrators(row["mam-narrators"])
book[hashKey].bestMAMMatch.setSeries(row["mam-seriesparts"])
book[hashKey].bestMAMMatch.setSeriesFromLog(row.get("mam-series", ""), row["mam-seriesparts"])

i += 1
except csv.Error as e:
Expand Down
11 changes: 8 additions & 3 deletions myx_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,14 @@ class MAMBook:
parsedName:dict=None
refresh:bool=False
matchAttempt:str=""
cacheKey:str=""

def getHashKey(self):
# log mode processes each CSV row on its own but used to cache by release name only, so the
# second disc of a #15-grouped book (same `book` column) was skipped as already processed
if self.cacheKey:
return myx_utilities.getHash(self.cacheKey)
return myx_utilities.getHash(self.name)

def getRunTimeLength(self):
#add all the duration of the files in the book, and convert into minutes
Expand Down Expand Up @@ -1188,9 +1196,6 @@ def getMAMBooks(self, cfg, bookFile:BookFile):
else:
return None

def getHashKey(self):
return myx_utilities.getHash(self.name)

def isCached(self, category, cfg):
return myx_utilities.isCached(self.getHashKey(),category, cfg)

Expand Down
110 changes: 110 additions & 0 deletions tests/test_logmode.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,113 @@ def test_hand_written_rows_still_accept_name_hash_part(self):
b = myx_classes.Book()
b.setSeriesFromLog("", "")
self.assertEqual(b.series, [])


class LogModeMultiDiscCacheTest(unittest.TestCase):
"""#15 logs both discs under the release folder name. Log mode caches by that name, so the
second row was `Skipping: … already processed` and cd2 never reached the library."""

def _guest_row(self, src, media, src_file, dest):
row = dict.fromkeys(myx_utilities.getLogHeaders().keys(), "")
row.update({
"book": "The Guest", "file": src_file, "paths": dest,
"isMatched": "True", "isHardLinked": "False", "mamCount": "0",
"audibleMatchCount": "1", "metadatasource": "audible",
"adb-asin": "B0GUEST0001", "adb-title": "The Guest", "adb-authors": "Lee Child",
"adb-series": "Jack Reacher", "adb-seriesparts": "Jack Reacher 2",
"adb-language": "english", "sourcePath": src, "mediaPath": media,
"id3-title": "AudioTrack 01", "id3-authors": "unknown artist",
"id3-duration": str(200 * 60), "id3-language": "english",
})
return row

def test_second_disc_of_a_logged_release_is_filed(self):
# cacheMe runs after every row's isCached check, so a single two-row run
# files both discs even with the old name-only key. The skip is the next
# run: a name-only marker from cd1 (or a prior hybrid pass) hid cd2.
import myx_hints
with tempfile.TemporaryDirectory() as td:
src, media = os.path.join(td, "src"), os.path.join(td, "media")
for disc in ("cd1", "cd2"):
os.makedirs(os.path.join(src, "The Guest", disc))
os.makedirs(media)
files, dests = [], []
for disc in ("cd1", "cd2"):
path = os.path.join(src, "The Guest", disc, "01.mp3")
with open(path, "wb") as fh:
fh.write(b"\x00" * 16)
files.append(path)
dests.append(os.path.join(
media, "Lee Child", "Jack Reacher", "Jack Reacher #2 - The Guest", f"The Guest {disc}"))
fix = os.path.join(td, "fix.csv")

def write_rows(rows):
with open(fix, "w", newline="", encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=list(myx_utilities.getLogHeaders().keys()))
w.writeheader()
w.writerows(rows)

def run_log():
myx_hints._cache.clear()
cfg = FakeConfig(td, **{"Config/metadata": "log"})
out = io.StringIO()
with contextlib.redirect_stdout(out):
booktree.buildTreeFromLog(fix, os.path.join(td, f"booktree_log_{len(os.listdir(td))}.csv"), cfg)
return out.getvalue()

write_rows([self._guest_row(src, media, files[0], dests[0])])
first = run_log()
self.assertTrue(os.path.exists(os.path.join(dests[0], "01.mp3")), first)
self.assertFalse(os.path.exists(os.path.join(dests[1], "01.mp3")), first)

write_rows([self._guest_row(src, media, files[0], dests[0]),
self._guest_row(src, media, files[1], dests[1])])
second = run_log()
linked = [os.path.join(dest, "01.mp3") for dest in dests]
self.assertTrue(os.path.exists(linked[0]), second)
self.assertTrue(os.path.exists(linked[1]), second)
self.assertTrue(os.path.samefile(files[0], linked[0]))
self.assertTrue(os.path.samefile(files[1], linked[1]))
self.assertIn("Skipping The Guest, already processed", second)
self.assertIn("Creating Hardlinks for 1 matched books", second)

def test_already_matched_series_uses_the_series_column_not_seriesparts(self):
"""#18 taught setSeriesFromLog to pair series + seriesparts; log mode still called
setSeries(adb-seriesparts), so a blanked paths column filed as 'Series 17.5 - Title'."""
import myx_hints
with tempfile.TemporaryDirectory() as td:
src, media = os.path.join(td, "src"), os.path.join(td, "media")
os.makedirs(os.path.join(src, "novella"))
os.makedirs(media)
source_file = os.path.join(src, "novella", "book.m4b")
with open(source_file, "wb") as fh:
fh.write(b"\x00" * 16)
fix = os.path.join(td, "fix.csv")
row = dict.fromkeys(myx_utilities.getLogHeaders().keys(), "")
row.update({
"book": "novella", "file": source_file, "paths": "",
"isMatched": "True", "isHardLinked": "False", "mamCount": "0",
"audibleMatchCount": "1", "metadatasource": "audible",
"adb-asin": "B0PIKE1750", "adb-title": "The Honeymoon Heist",
"adb-authors": "Brad Taylor", "adb-series": "Pike Logan",
"adb-seriesparts": "Pike Logan 17.5", "adb-language": "english",
"sourcePath": src, "mediaPath": media,
"id3-title": "The Honeymoon Heist", "id3-authors": "Brad Taylor",
"id3-duration": str(180 * 60), "id3-language": "english",
})
with open(fix, "w", newline="", encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=list(myx_utilities.getLogHeaders().keys()))
w.writeheader()
w.writerow(row)
myx_hints._cache.clear()
cfg = FakeConfig(td, **{"Config/metadata": "log"})
out = io.StringIO()
with contextlib.redirect_stdout(out):
booktree.buildTreeFromLog(fix, os.path.join(td, "booktree_log_series.csv"), cfg)
dest = os.path.join(media, "Brad Taylor", "Pike Logan", "Pike Logan #17.5 - The Honeymoon Heist")
self.assertTrue(os.path.exists(os.path.join(dest, "book.m4b")), out.getvalue())
self.assertFalse(os.path.exists(os.path.join(media, "Brad Taylor", "Pike Logan 17.5")))
with open(os.path.join(dest, "metadata.opf"), encoding="utf-8") as fh:
opf = fh.read()
self.assertIn("content='Pike Logan'", opf)
self.assertIn("content='17.5'", opf)
Loading