Files
wordarr/wordarr/importer/scanner.py
T

176 lines
6.9 KiB
Python

import re
from pathlib import Path
from .. import config
from ..fsnames import encode_path, readable
# "CD", "CD1", "CD 2", "Disc_03", "Teil 1", "Teil B" as folder name. The index is
# optional (single-disc rips just use "CD") and may be a letter. A bare number is
# deliberately not a disc: those folders are usually episodes of a series.
DISC_DIR_RE = re.compile(
r"^(?:cd|disc|disk|dvd|teil|part|vol|volume)[\s._-]*(\d{1,3}|[a-h])?$",
re.IGNORECASE,
)
# "A - Das Raetsel der Sphinx", "B - Das vergessene Volk": parts of one story,
# told apart only by a leading letter. Only used when *every* audio subfolder
# follows the pattern.
LETTER_PART_RE = re.compile(r"^([a-h])\s*[-–—._:]\s*\S", re.IGNORECASE)
# leading track/part index of a file name: "01 - ", "1. ", "[A] ", "B - ", "01 ".
# Only digits or a single letter count, so a title starting with a short word
# ("Der Hobbit …") keeps its first word.
LEADING_INDEX_RE = re.compile(
r"^\s*[\[(]?(?:\d{1,4}|[a-z])[\])]?\s*(?:[-–—._:]+\s*|\s+)", re.IGNORECASE
)
# a whole book in one file is big; chapter/track files are not
SEPARATE_TITLE_MIN_BYTES = 60 * 1024 * 1024
def media_type_for(ext: str) -> str | None:
ext = ext.lower()
if ext in config.EBOOK_EXTENSIONS:
return "ebook"
if ext in config.AUDIOBOOK_EXTENSIONS:
return "audiobook"
if ext in config.COMIC_EXTENSIONS:
return "comic"
return None
def natural_key(name: str) -> list:
"""Sort key so "Track 2" comes before "Track 10"."""
return [int(p) if p.isdigit() else p.lower() for p in re.split(r"(\d+)", name)]
def _index(token: str) -> int:
return int(token) if token.isdigit() else ord(token.lower()) - ord("a") + 1
def disc_number(name: str) -> int | None:
"""Disc index of a disc folder name, 0 for an unnumbered "CD", 1-8 for a
letter ("Teil B"). None if the name is not a disc folder at all."""
m = DISC_DIR_RE.match(name.strip())
if not m:
return None
return _index(m.group(1)) if m.group(1) else 0
def letter_part(name: str) -> int | None:
"""Index of an "A - Titel" style part folder, else None."""
m = LETTER_PART_RE.match(name.strip())
return _index(m.group(1)) if m else None
def separate_titles(files: list[Path]) -> bool:
"""True when a folder's audio files look like distinct books rather than the
parts of one audiobook: their names differ beyond a leading index, and each
file is large enough to be a whole book. Only a hint for the UI - the split
stays a manual decision, because chapter-per-file rips look similar."""
residuals = {LEADING_INDEX_RE.sub("", f.stem).strip().lower() for f in files}
if len(residuals) < 2:
return False
try:
return all(f.stat().st_size >= SEPARATE_TITLE_MIN_BYTES for f in files)
except OSError:
return False
def scan(root: Path, split_dirs: bool = False) -> list[dict]:
"""Scan the download dir. Returns items: single files, or a directory that is
an audiobook unit (contains >1 audio file, or disc subfolders like CD1/CD2,
and nothing but audio/junk).
With split_dirs=True every file is listed individually (e.g. for folders
holding many episodes of a series)."""
items = []
if not root.is_dir():
return items
def audio_files(d: Path) -> list[Path]:
return sorted(
(f for f in d.iterdir()
if f.is_file() and f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS),
key=lambda f: natural_key(f.name),
)
def subdirs(d: Path) -> list[Path]:
return [s for s in d.iterdir() if s.is_dir() and not s.name.startswith(".")]
def has_audio(d: Path) -> bool:
return any(
f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS
for f in d.rglob("*") if f.is_file()
)
def disc_audio(d: Path) -> list[Path] | None:
"""Audio files of d's disc subfolders ("CD", "CD1", "CD2", …), in disc
order. Subfolders without audio (Cover, Scans, …) are ignored. None if d
is not a disc-split folder."""
audio_subs = [s for s in subdirs(d) if has_audio(s)]
# artwork/booklet folders are already filtered out above
discs = [(disc_number(s.name), s) for s in audio_subs]
if any(num is None for num, _ in discs):
# not disc folders - but "A - …", "B - …" are parts of one story too,
# as long as every single one of them follows that shape
letters = [(letter_part(s.name), s) for s in audio_subs]
if len(letters) < 2 or any(num is None for num, _ in letters):
return None # real subfolders -> walk normally
discs = letters
files = []
for _, s in sorted(discs, key=lambda t: (t[0], natural_key(t[1].name))):
files.extend(audio_files(s))
return files or None
def rel_dir(d: Path) -> str:
try:
return str(d.relative_to(root)) if d != root else ""
except ValueError:
return ""
def walk(d: Path):
audio = audio_files(d)
if not split_dirs:
discs = disc_audio(d) if d != root else None
if discs:
items.append({
"path": encode_path(str(d)),
"name": readable(d.name),
"rel_dir": readable(rel_dir(d.parent)),
"media_type": "audiobook",
"is_dir": True,
"files": [encode_path(str(f)) for f in audio + discs],
"maybe_separate": False, # disc folders are one book by definition
})
return
# the download dir itself is a collection, never one audiobook:
# a couple of loose files in it would otherwise swallow every
# subfolder, because this branch returns early
if len(audio) > 1 and d != root:
items.append({
"path": encode_path(str(d)),
"name": readable(d.name),
"rel_dir": readable(rel_dir(d.parent)),
"media_type": "audiobook",
"is_dir": True,
"files": [encode_path(str(f)) for f in audio],
"maybe_separate": separate_titles(audio),
})
return
for entry in sorted(d.iterdir(), key=lambda p: natural_key(p.name)):
if entry.name.startswith("."):
continue
if entry.is_dir():
walk(entry)
elif entry.is_file():
mt = media_type_for(entry.suffix)
if mt:
items.append({
"path": encode_path(str(entry)),
"name": readable(entry.stem),
"rel_dir": readable(rel_dir(d)),
"media_type": mt,
"is_dir": False,
"files": [encode_path(str(entry))],
})
walk(root)
return items