176 lines
6.9 KiB
Python
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
|