From d07c6b033f94ab9793221333b33bd784addb81d9 Mon Sep 17 00:00:00 2001 From: MirSob Date: Sun, 26 Apr 2026 00:46:48 +0200 Subject: [PATCH] codex author changes --- .../apply_abs_mock_report.cpython-311.pyc | Bin 0 -> 14492 bytes .../generate_abs_mock_report.cpython-311.pyc | Bin 53739 -> 59412 bytes apply_abs_mock_report.py | 278 ++++++++++++++++++ ...obookshelf_mock_report_audiobooki_nowe.ods | Bin 0 -> 12573 bytes ...obookshelf_mock_report_audiobooki_nowe.tsv | 12 +- ...test_apply_abs_mock_report.cpython-311.pyc | Bin 0 -> 10950 bytes ...t_generate_abs_mock_report.cpython-311.pyc | Bin 6744 -> 10128 bytes tests/test_apply_abs_mock_report.py | 230 +++++++++++++++ tests/test_generate_abs_mock_report.py | 63 ++++ 9 files changed, 577 insertions(+), 6 deletions(-) create mode 100644 __pycache__/apply_abs_mock_report.cpython-311.pyc create mode 100644 apply_abs_mock_report.py create mode 100644 reports/audiobookshelf_mock_report_audiobooki_nowe.ods create mode 100644 tests/__pycache__/test_apply_abs_mock_report.cpython-311.pyc create mode 100644 tests/test_apply_abs_mock_report.py diff --git a/__pycache__/apply_abs_mock_report.cpython-311.pyc b/__pycache__/apply_abs_mock_report.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..39554e7768c126ce2dd1bea829a9ed9d0bb06dc3 GIT binary patch literal 14492 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 literal 0 HcmV?d00001 diff --git a/__pycache__/generate_abs_mock_report.cpython-311.pyc b/__pycache__/generate_abs_mock_report.cpython-311.pyc index c4f69ec0b168c1895e179bafe5e9929343f19c4e..b30796c4b3191b7a7845ec9d7d781a431289b85d 100644 GIT binary patch delta 16365 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 C0hm|- delta 11930 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 diff --git a/apply_abs_mock_report.py b/apply_abs_mock_report.py new file mode 100644 index 0000000..d16f0d0 --- /dev/null +++ b/apply_abs_mock_report.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import csv +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + + +REQUIRED_COLUMNS = {"current_path", "proposed_abs_path", "status"} + + +class ReportError(ValueError): + pass + + +@dataclass(frozen=True) +class ReportRow: + line_number: int + source_path: Path + target_rel_path: PurePosixPath + status: str + + +@dataclass(frozen=True) +class TransferOperation: + line_number: int + source_path: Path + target_rel_path: PurePosixPath + target_path: Path + + +def parse_report_row(raw_row: dict[str, str], line_number: int) -> ReportRow: + source_raw = (raw_row.get("current_path") or "").strip() + target_raw = (raw_row.get("proposed_abs_path") or "").strip() + status = (raw_row.get("status") or "").strip() + + if not source_raw: + raise ReportError(f"Line {line_number}: missing current_path") + if not target_raw: + raise ReportError(f"Line {line_number}: missing proposed_abs_path") + + target_rel_path = PurePosixPath(target_raw) + if target_rel_path.is_absolute(): + raise ReportError(f"Line {line_number}: proposed_abs_path must stay relative: {target_raw}") + if not target_rel_path.parts: + raise ReportError(f"Line {line_number}: proposed_abs_path is empty") + if any(part in {"", ".", ".."} for part in target_rel_path.parts): + raise ReportError( + f"Line {line_number}: proposed_abs_path contains an unsafe path component: {target_raw}" + ) + + return ReportRow( + line_number=line_number, + source_path=Path(source_raw).expanduser(), + target_rel_path=target_rel_path, + status=status, + ) + + +def load_report(path: Path) -> list[ReportRow]: + with path.open(encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle, delimiter="\t") + fieldnames = set(reader.fieldnames or []) + missing = REQUIRED_COLUMNS - fieldnames + if missing: + missing_fields = ", ".join(sorted(missing)) + raise ReportError(f"Report is missing required columns: {missing_fields}") + + rows: list[ReportRow] = [] + for line_number, raw_row in enumerate(reader, start=2): + if not any((value or "").strip() for value in raw_row.values()): + continue + rows.append(parse_report_row(raw_row, line_number)) + return rows + + +def is_same_or_child(path: Path, other: Path) -> bool: + try: + path.relative_to(other) + except ValueError: + return False + return True + + +def validate_non_overlapping_paths( + entries: list[tuple[Path, str]], + *, + kind: str, +) -> None: + for index, (path, description) in enumerate(entries): + for other_path, other_description in entries[index + 1 :]: + if is_same_or_child(path, other_path) or is_same_or_child(other_path, path): + raise ReportError( + f"{kind} paths overlap: {description} <-> {other_description}" + ) + + +def build_execution_plan( + rows: list[ReportRow], + destination_root: Path, + *, + selected_status: str, +) -> tuple[list[TransferOperation], int]: + selected_rows = rows if selected_status == "all" else [ + row for row in rows if row.status == selected_status + ] + skipped_rows = len(rows) - len(selected_rows) + + resolved_destination_root = destination_root.resolve() + operations: list[TransferOperation] = [] + seen_sources: dict[Path, int] = {} + seen_targets: dict[PurePosixPath, int] = {} + + for row in selected_rows: + source_path = row.source_path.resolve() + if not source_path.exists(): + raise ReportError( + f"Line {row.line_number}: source path does not exist: {row.source_path}" + ) + if not source_path.is_dir(): + raise ReportError( + f"Line {row.line_number}: source path is not a directory: {row.source_path}" + ) + + target_path = resolved_destination_root.joinpath(*row.target_rel_path.parts) + if target_path.exists(): + raise ReportError( + f"Line {row.line_number}: destination already exists: {target_path}" + ) + if is_same_or_child(target_path, source_path): + raise ReportError( + f"Line {row.line_number}: destination cannot point inside the source tree: {target_path}" + ) + + previous_source_line = seen_sources.get(source_path) + if previous_source_line is not None: + raise ReportError( + f"Line {row.line_number}: duplicate source path already used on line {previous_source_line}: {source_path}" + ) + previous_target_line = seen_targets.get(row.target_rel_path) + if previous_target_line is not None: + raise ReportError( + f"Line {row.line_number}: duplicate proposed_abs_path already used on line {previous_target_line}: " + f"{row.target_rel_path.as_posix()}" + ) + + seen_sources[source_path] = row.line_number + seen_targets[row.target_rel_path] = row.line_number + operations.append( + TransferOperation( + line_number=row.line_number, + source_path=source_path, + target_rel_path=row.target_rel_path, + target_path=target_path, + ) + ) + + validate_non_overlapping_paths( + [(operation.source_path, f"line {operation.line_number}: {operation.source_path}") for operation in operations], + kind="Source", + ) + validate_non_overlapping_paths( + [(operation.target_path, f"line {operation.line_number}: {operation.target_path}") for operation in operations], + kind="Destination", + ) + + return operations, skipped_rows + + +def execute_plan( + operations: list[TransferOperation], + *, + mode: str, + dry_run: bool, + verbose: bool, +) -> None: + for operation in operations: + if dry_run or verbose: + print( + f"{'plan' if dry_run else mode}\t{operation.source_path}\t{operation.target_path}" + ) + if dry_run: + continue + + operation.target_path.parent.mkdir(parents=True, exist_ok=True) + if mode == "copy": + shutil.copytree(operation.source_path, operation.target_path) + else: + shutil.move(str(operation.source_path), str(operation.target_path)) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Copy or move audiobook folders into the directory structure proposed by a mock ABS report." + ) + parser.add_argument( + "--report", + default="reports/audiobookshelf_mock_report.tsv", + help="TSV report produced by generate_abs_mock_report.py", + ) + parser.add_argument( + "--destination-root", + "--dest-root", + "--destination", + required=True, + help="Root directory where the new structure should be created", + ) + parser.add_argument( + "--mode", + choices=("copy", "move"), + default="copy", + help="Whether to copy or move each source directory", + ) + parser.add_argument( + "--status", + choices=("ready", "review", "all"), + default="ready", + help="Which report rows should be applied", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Validate the report and print the planned operations without changing files", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Print each completed operation", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + report_path = Path(args.report).expanduser().resolve() + destination_root = Path(args.destination_root).expanduser().resolve() + + try: + if not report_path.exists(): + raise ReportError(f"Report does not exist: {report_path}") + if destination_root.exists() and not destination_root.is_dir(): + raise ReportError(f"Destination root is not a directory: {destination_root}") + + rows = load_report(report_path) + operations, skipped_rows = build_execution_plan( + rows, + destination_root, + selected_status=args.status, + ) + execute_plan( + operations, + mode=args.mode, + dry_run=args.dry_run, + verbose=args.verbose, + ) + except (OSError, ReportError) as error: + print(error, file=sys.stderr) + return 1 + + print(f"report\t{report_path}") + print(f"destination_root\t{destination_root}") + print(f"mode\t{args.mode}") + print(f"selected_status\t{args.status}") + print(f"rows_total\t{len(rows)}") + print(f"rows_selected\t{len(operations)}") + print(f"rows_skipped\t{skipped_rows}") + print(f"dry_run\t{'yes' if args.dry_run else 'no'}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/reports/audiobookshelf_mock_report_audiobooki_nowe.ods b/reports/audiobookshelf_mock_report_audiobooki_nowe.ods new file mode 100644 index 0000000000000000000000000000000000000000..e3e4b1f86d5b02dcd46948d374e1b8497022e3d8 GIT binary patch literal 12573 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 literal 0 HcmV?d00001 diff --git a/reports/audiobookshelf_mock_report_audiobooki_nowe.tsv b/reports/audiobookshelf_mock_report_audiobooki_nowe.tsv index 75c87ae..4603854 100644 --- a/reports/audiobookshelf_mock_report_audiobooki_nowe.tsv +++ b/reports/audiobookshelf_mock_report_audiobooki_nowe.tsv @@ -2,9 +2,9 @@ verification_status verification_source verification_note status current_path au unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Abmercombie Joe/Abercrombie Joe - Cykl The Devils (tom 1) Diabły (Audiobooki2.pl) 73 Diabły (1).mp3 Abmercombie Joe high folder Abercrombie Joe - Cykl The Devils (tom 1) Diabły (Audiobooki2 pl) path Abmercombie Joe/Abercrombie Joe - Cykl The Devils (tom 1) Diably (Audiobooki2 pl) unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Abmercombie Joe/Abercrombie Joe - Czerwona kraina (Audiobooki2.pl) 42 Czerwona kraina (01).mp3 Abmercombie Joe high folder Abercrombie Joe - Czerwona kraina (Audiobooki2 pl) path Abmercombie Joe/Abercrombie Joe - Czerwona kraina (Audiobooki2 pl) unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Abmercombie Joe/Abercrombie Joe - Zemsta najlepiej smakuje na zimno (Audiobooki2.pl) 71 Zemsta najlepiej smakuje na zimno 01.mp3 Abmercombie Joe high folder Abercrombie Joe - Zemsta najlepiej smakuje na zimno (Audiobooki2 pl) path Abmercombie Joe/Abercrombie Joe - Zemsta najlepiej smakuje na zimno (Audiobooki2 pl) -unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Abmercombie Joe/Cykl Pierwsze Prawo/Joe Abercrombie - Cykl Pierwsze prawo (tom 2) Nim zawisna (Audiobooki2.pl) 73 01 Nim zawisną .mp3 Abmercombie Joe high folder Joe Abercrombie - Cykl Pierwsze prawo (tom 2) Nim zawisna (Audiobooki2 pl) path Abmercombie Joe/Joe Abercrombie - Cykl Pierwsze prawo (tom 2) Nim zawisna (Audiobooki2 pl) ignored grouping folder 'Cykl Pierwsze Prawo' -unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Abmercombie Joe/Cykl Pierwsze Prawo/Joe Abercrombie - Cykl Pierwsze prawo (tom 3) Ostateczny argument (Audiobooki2.pl) 86 Ostateczny argument (01).mp3 Abmercombie Joe high folder Joe Abercrombie - Cykl Pierwsze prawo (tom 3) Ostateczny argument (Audiobooki2 pl) path Abmercombie Joe/Joe Abercrombie - Cykl Pierwsze prawo (tom 3) Ostateczny argument (Audiobooki2 pl) ignored grouping folder 'Cykl Pierwsze Prawo' -unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Abmercombie Joe/Cykl Pierwsze Prawo/Joe Abercrombie - Cykl Pierwsze prawo Tom 01 Ostrze czyta Filip Kosior 128kbps 45 Ostrze (1).mp3 Abmercombie Joe high folder Joe Abercrombie - Cykl Pierwsze prawo Tom 01 Ostrze path Filip Kosior 128kbps Abmercombie Joe/Joe Abercrombie - Cykl Pierwsze prawo Tom 01 Ostrze {Filip Kosior 128kbps} ignored grouping folder 'Cykl Pierwsze Prawo'; narrator inferred from folder name +unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Abmercombie Joe/Cykl Pierwsze Prawo/Joe Abercrombie - Cykl Pierwsze prawo (tom 2) Nim zawisna (Audiobooki2.pl) 73 01 Nim zawisną .mp3 Abmercombie Joe high folder Abercrombie Cykl Pierwsze prawo tom 2 Nim zawisna Audiobooki2 pl path Abmercombie Joe/Abercrombie Cykl Pierwsze prawo tom 2 Nim zawisna Audiobooki2 pl ignored grouping folder 'Cykl Pierwsze Prawo' +unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Abmercombie Joe/Cykl Pierwsze Prawo/Joe Abercrombie - Cykl Pierwsze prawo (tom 3) Ostateczny argument (Audiobooki2.pl) 86 Ostateczny argument (01).mp3 Abmercombie Joe high folder Abercrombie Cykl Pierwsze prawo tom 3 Ostateczny argument Audiobooki2 pl path Abmercombie Joe/Abercrombie Cykl Pierwsze prawo tom 3 Ostateczny argument Audiobooki2 pl ignored grouping folder 'Cykl Pierwsze Prawo' +unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Abmercombie Joe/Cykl Pierwsze Prawo/Joe Abercrombie - Cykl Pierwsze prawo Tom 01 Ostrze czyta Filip Kosior 128kbps 45 Ostrze (1).mp3 Abmercombie Joe high folder Abercrombie Cykl Pierwsze prawo Tom 01 Ostrze path Filip Kosior 128kbps Abmercombie Joe/Abercrombie Cykl Pierwsze prawo Tom 01 Ostrze {Filip Kosior 128kbps} ignored grouping folder 'Cykl Pierwsze Prawo'; narrator inferred from folder name unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Adler-Olsen/Jussi Adler-Olsen - Departament Q tom 1-8 (Audiobooki.pl)/Adler-Olsen Jussi - Departament Q 01. Kobieta w Klatce 15 01. Departament Q Tom 1 - Kobieta w Klatce - Prolog.mp3 Jussi Adler-Olsen medium mixed-folder Departament Q 01 Kobieta w Klatce path Jussi Adler-Olsen/Departament Q/Vol. 01 - Kobieta w Klatce author inferred from a weak path signal; series normalized from folder context; sequence inferred from folder context; author came from nested folder 'Jussi Adler - Olsen - Departament Q tom 1 - 8 (Audiobooki pl)' unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Adler-Olsen/Jussi Adler-Olsen - Departament Q tom 1-8 (Audiobooki.pl)/Adler-Olsen Jussi - Departament Q 02. Zabójcy Bażantów 15 01. Departament Q Tom 2 - Zabójcy Bażantów - Prolog.mp3 Jussi Adler-Olsen medium mixed-folder Departament Q 02 Zabójcy Bażantów path Jussi Adler-Olsen/Departament Q/Vol. 02 - Zabojcy Bazantow author inferred from a weak path signal; series normalized from folder context; sequence inferred from folder context; author came from nested folder 'Jussi Adler - Olsen - Departament Q tom 1 - 8 (Audiobooki pl)' unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Adler-Olsen/Jussi Adler-Olsen - Departament Q tom 1-8 (Audiobooki.pl)/Adler-Olsen Jussi - Departament Q 03. Wybawienie 18 01. Departament Q Tom 3 - Wybawienie - Prolog.mp3 Jussi Adler-Olsen medium mixed-folder Departament Q 03 Wybawienie path Jussi Adler-Olsen/Departament Q/Vol. 03 - Wybawienie author inferred from a weak path signal; series normalized from folder context; sequence inferred from folder context; author came from nested folder 'Jussi Adler - Olsen - Departament Q tom 1 - 8 (Audiobooki pl)' @@ -16,7 +16,7 @@ unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Brandon Sanderson unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Brandon Sanderson - Legion (czyta T.Sobczak) 96kbps 32 01.Legion.mp3 Brandon Sanderson medium mixed-folder Legion 96kbps path T Sobczak Brandon Sanderson/Legion 96kbps {T Sobczak} author inferred from a weak path signal; narrator inferred from folder name unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Brandon Sanderson - Słoneczny mąż (czyta D.Odija) 107 kbps 19 01.mp3 Brandon Sanderson medium mixed-folder Słoneczny mąż 107 kbps path D Odija Brandon Sanderson/Sloneczny maz 107 kbps {D Odija} author inferred from a weak path signal; narrator inferred from folder name unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Brandon Sanderson - Warkocz ze Szmaragdowego Morza (czyta M. Kowalik) 128kbps 67 01.mp3 Brandon Sanderson medium mixed-folder Warkocz ze Szmaragdowego Morza 128kbps path M Kowalik Brandon Sanderson/Warkocz ze Szmaragdowego Morza 128kbps {M Kowalik} author inferred from a weak path signal; narrator inferred from folder name -unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Chmielarz Wojciech/Wojciech Chmielarz - Prosta sprawa (2020) czyta Przemysław Bluszcz [audiobook PL] (Audiobooki.pl) 53 01.mp3 Wojciech Chmielarz high folder Prosta sprawa (2020) path Przemysław Bluszcz [audiobook PL] (Audiobooki pl) Wojciech Chmielarz/Prosta sprawa (2020) {Przemyslaw Bluszcz [audiobook PL] (Audiobooki pl)} narrator inferred from folder name; author order normalized from current folder/file name +unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Chmielarz Wojciech/Wojciech Chmielarz - Prosta sprawa (2020) czyta Przemysław Bluszcz [audiobook PL] (Audiobooki.pl) 52 01.mp3 Wojciech Chmielarz high folder Prosta sprawa (2020) path Przemysław Bluszcz [audiobook PL] (Audiobooki pl) Wojciech Chmielarz/Prosta sprawa (2020) {Przemyslaw Bluszcz [audiobook PL] (Audiobooki pl)} narrator inferred from folder name; author order normalized from current folder/file name unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Chmielarz Wojciech/Wojciech Chmielarz - Wampir czyta Mateusz Znaniecki 160kbps 80 00 Wampir.mp3 Wojciech Chmielarz high folder Wampir path Mateusz Znaniecki 160kbps Wojciech Chmielarz/Wampir {Mateusz Znaniecki 160kbps} narrator inferred from folder name; author order normalized from current folder/file name unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Chmielarz Wojciech/Wojciech Chmielarz - Zombie czyta Grzegorz Przybył 96kbps 15 01.mp3 Wojciech Chmielarz high folder Zombie path Grzegorz Przybył 96kbps Wojciech Chmielarz/Zombie {Grzegorz Przybyl 96kbps} narrator inferred from folder name; author order normalized from current folder/file name unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Chmielarz Wojciech/Ćwiek Jakub i Chmielarz Wojciech - Skowyt 8 1.mp3 Chmielarz Wojciech high folder Ćwiek Jakub i Chmielarz Wojciech - Skowyt path Chmielarz Wojciech/Cwiek Jakub i Chmielarz Wojciech - Skowyt @@ -35,8 +35,8 @@ unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Coben_Bolitar/Cob unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Dan Simons/Dan Simmons - cykl Hyperion (tom 2) Upadek Hyperiona (Audiobooki2.pl)/Dan Simmons - cykl Hyperion (tom 2) Upadek Hyperiona 46 01 Upadek Hyperiona.mp3 Dan Simons high folder Dan Simmons - cykl Hyperion (tom 2) Upadek Hyperiona path Dan Simons/Dan Simmons - cykl Hyperion (tom 2) Upadek Hyperiona ignored grouping folder 'Dan Simmons - cykl Hyperion (tom 2) Upadek Hyperiona (Audiobooki2 pl)' unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Dan Simons/Dan Simmons - cykl Hyperion (tom 3) Endymion (Audiobooki2.pl) 106 001 Endymion.mp3 Dan Simons high folder Dan Simmons - cykl Hyperion (tom 3) Endymion (Audiobooki2 pl) path Dan Simons/Dan Simmons - cykl Hyperion (tom 3) Endymion (Audiobooki2 pl) unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Dan Simons/Dan Simmons - cykl Hyperion (tom 4) Triumf Endymiona (Audiobooki2.pl)/Dan Simmons - cykl Hyperion (tom 4) Triumf Endymiona 148 001 Triumf Endymiona.mp3 Dan Simons high folder Dan Simmons - cykl Hyperion (tom 4) Triumf Endymiona path Dan Simons/Dan Simmons - cykl Hyperion (tom 4) Triumf Endymiona ignored grouping folder 'Dan Simmons - cykl Hyperion (tom 4) Triumf Endymiona (Audiobooki2 pl)' -unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Glukhowsky Dmitry/Metro 2033/Dmitry Glukhovsky - Metro 2033 czyta Krzysztof Gosztyła (Audiobooki.pl) 44 01. Metro 2033.mp3 Glukhowsky Dmitry high folder Dmitry Glukhovsky Metro 2033 path Krzysztof Gosztyła (Audiobooki pl) Glukhowsky Dmitry/Dmitry Glukhovsky/Metro 2033 {Krzysztof Gosztyla (Audiobooki pl)} ignored grouping folder 'Metro 2033'; narrator inferred from folder name; series normalized from folder context -unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Glukhowsky Dmitry/Metro 2034/Dmitry Glukhovsky - Metro 2034 czyta Krzysztof Gosztyła [audiobook PL] (Audiobooki.pl) 40 01 Metro 2034.mp3 Glukhowsky Dmitry high folder Dmitry Glukhovsky Metro 2034 path Krzysztof Gosztyła [audiobook PL] (Audiobooki pl) Glukhowsky Dmitry/Dmitry Glukhovsky/Metro 2034 {Krzysztof Gosztyla [audiobook PL] (Audiobooki pl)} ignored grouping folder 'Metro 2034'; narrator inferred from folder name; series normalized from folder context +unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Glukhowsky Dmitry/Metro 2033/Dmitry Glukhovsky - Metro 2033 czyta Krzysztof Gosztyła (Audiobooki.pl) 44 01. Metro 2033.mp3 Glukhowsky Dmitry high folder Glukhovsky Metro 2033 path Krzysztof Gosztyła Audiobooki pl Glukhowsky Dmitry/Glukhovsky/Metro 2033 {Krzysztof Gosztyla Audiobooki pl} ignored grouping folder 'Metro 2033'; narrator inferred from folder name; series normalized from folder context +unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Glukhowsky Dmitry/Metro 2034/Dmitry Glukhovsky - Metro 2034 czyta Krzysztof Gosztyła [audiobook PL] (Audiobooki.pl) 40 01 Metro 2034.mp3 Glukhowsky Dmitry high folder Glukhovsky Metro 2034 path Krzysztof Gosztyła audiobook PL Audiobooki pl Glukhowsky Dmitry/Glukhovsky/Metro 2034 {Krzysztof Gosztyla audiobook PL Audiobooki pl} ignored grouping folder 'Metro 2034'; narrator inferred from folder name; series normalized from folder context unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Glukhowsky Dmitry/Metro 2035/Dimitry Glukhovsky - Metro 2035 czyta Krzysztof Gosztyła (Audiobooki2.pl) 54 (eds-pl) Dmitry_Glukhovsky-METRO 2035 (01).mp3 Glukhowsky Dmitry high folder Dimitry Glukhovsky - Metro 2035 path Krzysztof Gosztyła (Audiobooki2 pl) Glukhowsky Dmitry/Dimitry Glukhovsky - Metro 2035 {Krzysztof Gosztyla (Audiobooki2 pl)} ignored grouping folder 'Metro 2035'; narrator inferred from folder name unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Kuzminska Malgorzata Michal/Cykl Anna Serfin Sebastian Strzygon/T1-Sleboda 13 Śleboda (1).mp3 Kuzminska Malgorzata Michal high folder 1 Sleboda path Kuzminska Malgorzata Michal/Vol. 1 - Sleboda ignored grouping folder 'Cykl Anna Serfin Sebastian Strzygon'; sequence inferred from folder name unverified ready /mnt/nextcloudExtDS/Ksiazki/Audiobooki-Nowe/Kuzminska Malgorzata Michal/Cykl Anna Serfin Sebastian Strzygon/T2-Pionek 17 01 Pionek.mp3 Kuzminska Malgorzata Michal high folder 2 Pionek path Kuzminska Malgorzata Michal/Vol. 2 - Pionek ignored grouping folder 'Cykl Anna Serfin Sebastian Strzygon'; sequence inferred from folder name diff --git a/tests/__pycache__/test_apply_abs_mock_report.cpython-311.pyc b/tests/__pycache__/test_apply_abs_mock_report.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..efc77751c677f34bb49f13594f96e370a40e9c75 GIT binary patch literal 10950 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_generate_abs_mock_report.cpython-311.pyc b/tests/__pycache__/test_generate_abs_mock_report.cpython-311.pyc index 5451c3dbdc9c14f66ba49a4149a3f9a54351f334..d3ffe2afd13328d446431c9d11b2b0b727927a25 100644 GIT binary patch delta 2835 zcmb_e-EZ4e6t~kRy-l4q?Yb`MK6*D0ZG_gO>)Nq)t?1gaHHCJgsQb{6ablO4#CEuL z)YTef5)+!>fmYUFNFYr@8f?5E^_}s+0}@C$QzWbkgy0Vto7fXioa?k{ny}F!uI%5j zeLn8d`JH3iIlMgE{CObYXJCa!?#bVEtTcbaez@}E=&P-w?@Imo2C-Fa0Es{%kc}W4 zLHa@ZK{kPG0?C2oKn6etKsJMH2H66#1!NFp5M-<9ons1)GAQcX9Xjr)7&v8yQylxkaUBu zibAb;O_2?q7ji~QBm4r<6kg9I7PO2g5#5}yR)X#MCv}^L9raTHmziHe`--7KhwE~< zsKW&vuG!&w0Urb!!v2R0z}3M0Hl{J~6AIl$A%_b)+{6QKT_Y#~?o;S?(FMXm)a}qq z39fq-y?LP5`;YYcW=NEl%Qr@nDT$EdaAP)23$lu*@K{31n4NKHVSz|XxMUHn(2;Sl zCX)bLO8;C<=_9~kZS6HRv&8EXktLlk)!#g^z4F(3N;CBmbsM&BckGVn29dKmuSx;|niRoEreyW?jk(DZX+gf|j*oS( zV^4hB*1RDb8R<<**AYo9nUu2zKR<7SK=;4cTbOL0oB4uJ5>h z=94{k_f!GJizsfRcv-S}bPfiFb zo|6?#)uZc|^csjwE~PX0P>$An;TGS z88b=)=eHSolj)(&IBtX|2`~)%l5T+VB-1y++ORvy+b36rmoCh@%dr`!!+iR`IloO) zc?aM!Q*MxXC(vbc7#d`6{tK>v28w9FMg!$G*yxb61$3^6&e`Z(Syj;?C;yKQvA%Bo zVWUBEBar5JN|B|EKuml_dm|}J$yD^)cNb+TjpIT(m%uWvwSW$qOKXcuhUx9w?1r{l zL$@o|d?n-3RENh?@Exn#aowPnOG@QvC6!7pW5`vryqJn+ctC#v^^dJP;i1uK4@=`cS}dO}?tJD!$>8Buy9@PJ Hv@`NI?Mp$0 delta 325 zcmbQ>f5U`tIWI340}xm^yv=;dJCW}IW8TE`cBVWjY%MHN>?u4c>_D0WNOJ&bP9V(* zq`81J7m(%#(%e9rCxtbbL6diL7GnZ8W7X!l|@#Q=gCTMek&%x$fe0t!~vA{)8w7pq2el_ z4>HFLL|B6e`^n!_}6DPk{voiBxWCW_lPELrpBC7TgEcpdlNgUJUbahPtW{FS4 diff --git a/tests/test_apply_abs_mock_report.py b/tests/test_apply_abs_mock_report.py new file mode 100644 index 0000000..8e42ccb --- /dev/null +++ b/tests/test_apply_abs_mock_report.py @@ -0,0 +1,230 @@ +import csv +import io +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import apply_abs_mock_report as apply_report + + +class ApplyAbsMockReportTests(unittest.TestCase): + def write_report(self, path: Path, rows: list[dict[str, str]]) -> None: + fieldnames = [ + "verification_status", + "verification_source", + "verification_note", + "status", + "current_path", + "audio_file_count", + "sample_audio_file", + "author", + "author_confidence", + "author_source", + "series", + "sequence", + "publish_year", + "title", + "title_source", + "narrator", + "proposed_abs_path", + "notes", + ] + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames, delimiter="\t") + writer.writeheader() + writer.writerows(rows) + + def create_book(self, root: Path, relative_path: str) -> Path: + book_root = root / relative_path + (book_root / "Disc 1").mkdir(parents=True) + (book_root / "Disc 1" / "01.mp3").write_bytes(b"audio") + (book_root / "cover.jpg").write_bytes(b"cover") + return book_root + + def test_copy_mode_recreates_report_structure(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + source_root = tmp / "source" + destination_root = tmp / "destination" + report_path = tmp / "report.tsv" + book_root = self.create_book(source_root, "Old Author/Old Book") + + self.write_report( + report_path, + [ + { + "status": "ready", + "current_path": str(book_root), + "proposed_abs_path": "New Author/New Series/Vol. 01 - New Book", + } + ], + ) + + exit_code = apply_report.main( + [ + "--report", + str(report_path), + "--destination-root", + str(destination_root), + "--mode", + "copy", + ] + ) + + self.assertEqual(exit_code, 0) + copied_root = destination_root / "New Author" / "New Series" / "Vol. 01 - New Book" + self.assertTrue((copied_root / "Disc 1" / "01.mp3").exists()) + self.assertTrue((copied_root / "cover.jpg").exists()) + self.assertTrue((book_root / "Disc 1" / "01.mp3").exists()) + + def test_move_mode_removes_source_tree(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + source_root = tmp / "source" + destination_root = tmp / "destination" + report_path = tmp / "report.tsv" + book_root = self.create_book(source_root, "Old Author/Old Book") + + self.write_report( + report_path, + [ + { + "status": "ready", + "current_path": str(book_root), + "proposed_abs_path": "Author/Book", + } + ], + ) + + exit_code = apply_report.main( + [ + "--report", + str(report_path), + "--destination-root", + str(destination_root), + "--mode", + "move", + ] + ) + + self.assertEqual(exit_code, 0) + moved_root = destination_root / "Author" / "Book" + self.assertTrue((moved_root / "Disc 1" / "01.mp3").exists()) + self.assertFalse(book_root.exists()) + + def test_dry_run_prints_plan_without_creating_destination(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + source_root = tmp / "source" + destination_root = tmp / "destination" + report_path = tmp / "report.tsv" + book_root = self.create_book(source_root, "Old Author/Old Book") + + self.write_report( + report_path, + [ + { + "status": "ready", + "current_path": str(book_root), + "proposed_abs_path": "Author/Book", + } + ], + ) + + stdout = io.StringIO() + with mock.patch("sys.stdout", stdout): + exit_code = apply_report.main( + [ + "--report", + str(report_path), + "--destination-root", + str(destination_root), + "--dry-run", + ] + ) + + self.assertEqual(exit_code, 0) + self.assertIn("plan\t", stdout.getvalue()) + self.assertFalse(destination_root.exists()) + + def test_duplicate_targets_fail_validation(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + source_root = tmp / "source" + report_path = tmp / "report.tsv" + first_book = self.create_book(source_root, "Author A/Book One") + second_book = self.create_book(source_root, "Author B/Book Two") + + self.write_report( + report_path, + [ + { + "status": "ready", + "current_path": str(first_book), + "proposed_abs_path": "Author/Shared", + }, + { + "status": "ready", + "current_path": str(second_book), + "proposed_abs_path": "Author/Shared", + }, + ], + ) + + stderr = io.StringIO() + with mock.patch("sys.stderr", stderr): + exit_code = apply_report.main( + [ + "--report", + str(report_path), + "--destination-root", + str(tmp / "destination"), + ] + ) + + self.assertEqual(exit_code, 1) + self.assertIn("duplicate proposed_abs_path", stderr.getvalue()) + + def test_default_status_only_applies_ready_rows(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + source_root = tmp / "source" + destination_root = tmp / "destination" + report_path = tmp / "report.tsv" + ready_book = self.create_book(source_root, "Ready Author/Ready Book") + review_book = self.create_book(source_root, "Review Author/Review Book") + + self.write_report( + report_path, + [ + { + "status": "ready", + "current_path": str(ready_book), + "proposed_abs_path": "Ready Author/Ready Book", + }, + { + "status": "review", + "current_path": str(review_book), + "proposed_abs_path": "Review Author/Review Book", + }, + ], + ) + + exit_code = apply_report.main( + [ + "--report", + str(report_path), + "--destination-root", + str(destination_root), + ] + ) + + self.assertEqual(exit_code, 0) + self.assertTrue((destination_root / "Ready Author" / "Ready Book").exists()) + self.assertFalse((destination_root / "Review Author" / "Review Book").exists()) + self.assertTrue(review_book.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_generate_abs_mock_report.py b/tests/test_generate_abs_mock_report.py index 2769a8a..ef9fd0d 100644 --- a/tests/test_generate_abs_mock_report.py +++ b/tests/test_generate_abs_mock_report.py @@ -83,6 +83,69 @@ class InferBookTests(unittest.TestCase): self.assertEqual(row["sequence"], "05") self.assertEqual(row["title"], "Pogrzebany") + def test_build_proposed_path_omits_author_from_subfolders(self) -> None: + proposed_path = report.build_proposed_abs_path( + "Jussi Adler-Olsen", + "Adler-Olsen Jussi - Departament Q", + "04", + "", + "Kartoteka 64 - Jussi Adler-Olsen", + "", + ) + + self.assertEqual( + proposed_path, + "Jussi Adler-Olsen/Departament Q/Vol. 04 - Kartoteka 64", + ) + + def test_build_proposed_path_omits_author_only_series_folder(self) -> None: + proposed_path = report.build_proposed_abs_path( + "Jeffrey Archer", + "Archer", + "", + "", + "Kain I Abel", + "", + ) + + self.assertEqual(proposed_path, "Jeffrey Archer/Kain I Abel") + + def test_strips_nearly_matching_author_prefix_from_title(self) -> None: + row = self.infer_row( + "Abmercombie Joe/Abercrombie Joe - Czerwona kraina (Audiobooki2.pl)", + ["Czerwona kraina (01).mp3"], + ) + + self.assertEqual(row["author"], "Abmercombie Joe") + self.assertEqual(row["title"], "Czerwona kraina (Audiobooki2 pl)") + self.assertEqual( + row["proposed_abs_path"], + "Abmercombie Joe/Czerwona kraina (Audiobooki2 pl)", + ) + + def test_strips_nearly_matching_author_prefix_from_nested_title(self) -> None: + row = self.infer_row( + "Dan Simons/Dan Simmons - cykl Hyperion (tom 3) Endymion (Audiobooki2.pl)", + ["001 Endymion.mp3"], + ) + + self.assertEqual(row["author"], "Dan Simons") + self.assertEqual(row["title"], "cykl Hyperion (tom 3) Endymion (Audiobooki2 pl)") + self.assertEqual( + row["proposed_abs_path"], + "Dan Simons/cykl Hyperion (tom 3) Endymion (Audiobooki2 pl)", + ) + + def test_strips_multi_author_prefix_when_current_author_is_in_root(self) -> None: + row = self.infer_row( + "Chmielarz Wojciech/Ćwiek Jakub i Chmielarz Wojciech - Skowyt", + ["1.mp3"], + ) + + self.assertEqual(row["author"], "Chmielarz Wojciech") + self.assertEqual(row["title"], "Skowyt") + self.assertEqual(row["proposed_abs_path"], "Chmielarz Wojciech/Skowyt") + if __name__ == "__main__": unittest.main()